From 91981159d2c5a101b813b5b847a61a4bf2d8aa53 Mon Sep 17 00:00:00 2001 From: Ryan Di Date: Wed, 25 Mar 2026 19:15:56 +1100 Subject: [PATCH] feat: wire schema migration plugins end-to-end --- packages/excalidraw/actions/actionExport.tsx | 6 +- packages/excalidraw/components/App.tsx | 27 ++- packages/excalidraw/data/blob.ts | 17 +- packages/excalidraw/data/json.ts | 10 +- packages/excalidraw/data/library.ts | 173 ++++++++++++------ packages/excalidraw/data/restore.ts | 43 +++-- packages/excalidraw/data/schema.test.ts | 78 ++++++++ packages/excalidraw/data/schema.ts | 157 +++++++++++++--- packages/excalidraw/index.tsx | 2 + .../excalidraw/tests/data/restore.test.ts | 38 ++++ packages/excalidraw/types.ts | 8 + 11 files changed, 460 insertions(+), 99 deletions(-) diff --git a/packages/excalidraw/actions/actionExport.tsx b/packages/excalidraw/actions/actionExport.tsx index e47a5bb84c..ae10fedad5 100644 --- a/packages/excalidraw/actions/actionExport.tsx +++ b/packages/excalidraw/actions/actionExport.tsx @@ -271,7 +271,11 @@ export const actionLoadScene = register({ elements: loadedElements, appState: loadedAppState, files, - } = await loadFromJSON(appState, elements); + } = await loadFromJSON( + appState, + elements, + app.getSchemaMigrationRegistry(), + ); return { elements: loadedElements, appState: loadedAppState, diff --git a/packages/excalidraw/components/App.tsx b/packages/excalidraw/components/App.tsx index d0ad28409f..453a5ca4ab 100644 --- a/packages/excalidraw/components/App.tsx +++ b/packages/excalidraw/components/App.tsx @@ -354,6 +354,7 @@ import { import { exportCanvas, loadFromBlob } from "../data"; import Library, { distributeLibraryItemsOnSquareGrid } from "../data/library"; import { restoreAppState, restoreElements } from "../data/restore"; +import { createSchemaMigrationRegistry } from "../data/schema"; import { getCenter, getDistance } from "../gesture"; import { History } from "../history"; import { defaultLang, getLanguage, languages, setLanguage, t } from "../i18n"; @@ -460,6 +461,7 @@ import type { import type { ClipboardData, PastedMixedContent } from "../clipboard"; import type { ExportedElements } from "../data"; +import type { SchemaMigrationRegistry } from "../data/schema"; import type { ContextMenuItems } from "./ContextMenu"; import type { FileSystemHandle } from "../data/filesystem"; @@ -606,6 +608,7 @@ class App extends React.Component { public fonts: Fonts; public renderer: Renderer; public visibleElements: readonly NonDeletedExcalidrawElement[]; + private schemaMigrationRegistry: SchemaMigrationRegistry; private resizeObserver: ResizeObserver | undefined; public library: AppClassProperties["library"]; public libraryItemsFromStorage: LibraryItems | undefined; @@ -722,6 +725,9 @@ class App extends React.Component { this.stylesPanelMode = deriveStylesPanelMode(this.editorInterface); this.id = nanoid(); + this.schemaMigrationRegistry = createSchemaMigrationRegistry( + props.schemaPlugins, + ); this.library = new Library(this); this.actionManager = new ActionManager( this.syncActionResult, @@ -768,6 +774,7 @@ class App extends React.Component { setCursor: this.setCursor, resetCursor: this.resetCursor, getEditorInterface: () => this.editorInterface, + getSchemaMigrationRegistry: this.getSchemaMigrationRegistry, updateFrameRendering: this.updateFrameRendering, toggleSidebar: this.toggleSidebar, onChange: (cb) => this.onChangeEmitter.on(cb), @@ -2320,6 +2327,10 @@ class App extends React.Component { return this.scene.getNonDeletedElements(); }; + public getSchemaMigrationRegistry = () => { + return this.schemaMigrationRegistry; + }; + public onInsertElements = (elements: readonly ExcalidrawElement[]) => { this.addElementsFromPasteOrLibrary({ elements, @@ -2808,6 +2819,7 @@ class App extends React.Component { const restoredElements = restoreElements(initialData?.elements, null, { repairBindings: true, deleteInvisibleElements: true, + schemaMigrationRegistry: this.schemaMigrationRegistry, }); let restoredAppState = restoreAppState(initialData?.appState, null); const activeTool = restoredAppState.activeTool; @@ -3219,6 +3231,12 @@ class App extends React.Component { } componentDidUpdate(prevProps: AppProps, prevState: AppState) { + if (prevProps.schemaPlugins !== this.props.schemaPlugins) { + this.schemaMigrationRegistry = createSchemaMigrationRegistry( + this.props.schemaPlugins, + ); + } + this.updateEmbeddables(); const elements = this.scene.getElementsIncludingDeleted(); const elementsMap = this.scene.getElementsMapIncludingDeleted(); @@ -3722,6 +3740,7 @@ class App extends React.Component { }) => { const elements = restoreElements(opts.elements, null, { deleteInvisibleElements: true, + schemaMigrationRegistry: this.schemaMigrationRegistry, }); const [minX, minY, maxX, maxY] = getCommonBounds(elements); @@ -11428,6 +11447,7 @@ class App extends React.Component { this.state, this.scene.getElementsIncludingDeleted(), fileHandle, + this.schemaMigrationRegistry, ); this.syncActionResult({ ...scene, @@ -11474,7 +11494,11 @@ class App extends React.Component { ); // legacy library dataTransfer format } else if (excalidrawLibrary_data) { - libraryItems = parseLibraryJSON(excalidrawLibrary_data); + libraryItems = parseLibraryJSON( + excalidrawLibrary_data, + "unpublished", + this.schemaMigrationRegistry, + ); } if (libraryItems?.length) { libraryItems = libraryItems.map((item) => ({ @@ -11544,6 +11568,7 @@ class App extends React.Component { this.state, elements, fileHandle, + this.schemaMigrationRegistry, ); } catch (error: any) { const imageSceneDataError = error instanceof ImageSceneDataError; diff --git a/packages/excalidraw/data/blob.ts b/packages/excalidraw/data/blob.ts index c520c33b1c..fc3bd75f91 100644 --- a/packages/excalidraw/data/blob.ts +++ b/packages/excalidraw/data/blob.ts @@ -28,6 +28,7 @@ import { import type { AppState, DataURL, LibraryItem } from "../types"; import type { FileSystemHandle } from "browser-fs-access"; +import type { SchemaMigrationRegistry } from "./schema"; import type { ImportedLibraryData } from "./types"; const parseFileContents = async (blob: Blob | File): Promise => { @@ -141,6 +142,7 @@ export const loadSceneOrLibraryFromBlob = async ( localElements: readonly ExcalidrawElement[] | null, /** FileSystemHandle. Defaults to `blob.handle` if defined, otherwise null. */ fileHandle?: FileSystemHandle | null, + schemaMigrationRegistry?: SchemaMigrationRegistry, ) => { const contents = await parseFileContents(blob); let data; @@ -163,6 +165,7 @@ export const loadSceneOrLibraryFromBlob = async ( elements: restoreElements(data.elements, localElements, { repairBindings: true, deleteInvisibleElements: true, + schemaMigrationRegistry, }), appState: restoreAppState( { @@ -200,12 +203,14 @@ export const loadFromBlob = async ( localElements: readonly ExcalidrawElement[] | null, /** FileSystemHandle. Defaults to `blob.handle` if defined, otherwise null. */ fileHandle?: FileSystemHandle | null, + schemaMigrationRegistry?: SchemaMigrationRegistry, ) => { const ret = await loadSceneOrLibraryFromBlob( blob, localAppState, localElements, fileHandle, + schemaMigrationRegistry, ); if (ret.type !== MIME_TYPES.excalidraw) { throw new Error("Error: invalid file"); @@ -216,20 +221,28 @@ export const loadFromBlob = async ( export const parseLibraryJSON = ( json: string, defaultStatus: LibraryItem["status"] = "unpublished", + schemaMigrationRegistry?: SchemaMigrationRegistry, ) => { const data: ImportedLibraryData | undefined = JSON.parse(json); if (!isValidLibrary(data)) { throw new Error("Invalid library"); } const libraryItems = data.libraryItems || data.library; - return restoreLibraryItems(libraryItems, defaultStatus); + return restoreLibraryItems(libraryItems, defaultStatus, { + schemaMigrationRegistry, + }); }; export const loadLibraryFromBlob = async ( blob: Blob, defaultStatus: LibraryItem["status"] = "unpublished", + schemaMigrationRegistry?: SchemaMigrationRegistry, ) => { - return parseLibraryJSON(await parseFileContents(blob), defaultStatus); + return parseLibraryJSON( + await parseFileContents(blob), + defaultStatus, + schemaMigrationRegistry, + ); }; export const canvasToBlob = async ( diff --git a/packages/excalidraw/data/json.ts b/packages/excalidraw/data/json.ts index 047a2ccdec..762b305c04 100644 --- a/packages/excalidraw/data/json.ts +++ b/packages/excalidraw/data/json.ts @@ -14,6 +14,7 @@ import { isImageFileHandle, loadFromBlob } from "./blob"; import { fileOpen, fileSave } from "./filesystem"; import type { AppState, BinaryFiles, LibraryItems } from "../types"; +import type { SchemaMigrationRegistry } from "./schema"; import type { ExportedDataState, ImportedDataState, @@ -93,6 +94,7 @@ export const saveAsJSON = async ( export const loadFromJSON = async ( localAppState: AppState, localElements: readonly ExcalidrawElement[] | null, + schemaMigrationRegistry?: SchemaMigrationRegistry, ) => { const file = await fileOpen({ description: "Excalidraw files", @@ -100,7 +102,13 @@ export const loadFromJSON = async ( // gets resolved. Else, iOS users cannot open `.excalidraw` files. // extensions: ["json", "excalidraw", "png", "svg"], }); - return loadFromBlob(file, localAppState, localElements, file.handle); + return loadFromBlob( + file, + localAppState, + localElements, + file.handle, + schemaMigrationRegistry, + ); }; export const isValidExcalidrawData = (data?: { diff --git a/packages/excalidraw/data/library.ts b/packages/excalidraw/data/library.ts index 478b4ac27b..1486b70c96 100644 --- a/packages/excalidraw/data/library.ts +++ b/packages/excalidraw/data/library.ts @@ -35,6 +35,7 @@ import { loadLibraryFromBlob } from "./blob"; import { restoreLibraryItems } from "./restore"; import type App from "../components/App"; +import type { SchemaMigrationRegistry } from "./schema"; import type { LibraryItems, @@ -316,9 +317,15 @@ class Library { let nextItems; if (source instanceof Blob) { - nextItems = await loadLibraryFromBlob(source, defaultStatus); + nextItems = await loadLibraryFromBlob( + source, + defaultStatus, + this.app.getSchemaMigrationRegistry(), + ); } else { - nextItems = restoreLibraryItems(source, defaultStatus); + nextItems = restoreLibraryItems(source, defaultStatus, { + schemaMigrationRegistry: this.app.getSchemaMigrationRegistry(), + }); } if ( !prompt || @@ -551,12 +558,17 @@ class AdapterTransaction { adapter: LibraryPersistenceAdapter, source: LibraryAdatapterSource, _queue = true, + schemaMigrationRegistry?: SchemaMigrationRegistry, ): Promise { const task = () => new Promise(async (resolve, reject) => { try { const data = await adapter.load({ source }); - resolve(restoreLibraryItems(data?.libraryItems || [], "published")); + resolve( + restoreLibraryItems(data?.libraryItems || [], "published", { + schemaMigrationRegistry, + }), + ); } catch (error: any) { reject(error); } @@ -571,22 +583,36 @@ class AdapterTransaction { static run = async ( adapter: LibraryPersistenceAdapter, + schemaMigrationRegistry: SchemaMigrationRegistry | undefined, fn: (transaction: AdapterTransaction) => Promise, ) => { - const transaction = new AdapterTransaction(adapter); + const transaction = new AdapterTransaction( + adapter, + schemaMigrationRegistry, + ); return AdapterTransaction.queue.push(() => fn(transaction)); }; // ------------------ private adapter: LibraryPersistenceAdapter; + private schemaMigrationRegistry: SchemaMigrationRegistry | undefined; - constructor(adapter: LibraryPersistenceAdapter) { + constructor( + adapter: LibraryPersistenceAdapter, + schemaMigrationRegistry: SchemaMigrationRegistry | undefined, + ) { this.adapter = adapter; + this.schemaMigrationRegistry = schemaMigrationRegistry; } getLibraryItems(source: LibraryAdatapterSource) { - return AdapterTransaction.getLibraryItems(this.adapter, source, false); + return AdapterTransaction.getLibraryItems( + this.adapter, + source, + false, + this.schemaMigrationRegistry, + ); } } @@ -609,68 +635,73 @@ export const getLibraryItemsHash = (items: LibraryItems) => { const persistLibraryUpdate = async ( adapter: LibraryPersistenceAdapter, update: LibraryUpdate, + schemaMigrationRegistry: SchemaMigrationRegistry | undefined, ): Promise => { try { librarySaveCounter++; - return await AdapterTransaction.run(adapter, async (transaction) => { - const nextLibraryItemsMap = arrayToMap( - await transaction.getLibraryItems("save"), - ); + return await AdapterTransaction.run( + adapter, + schemaMigrationRegistry, + async (transaction) => { + const nextLibraryItemsMap = arrayToMap( + await transaction.getLibraryItems("save"), + ); - for (const [id] of update.deletedItems) { - nextLibraryItemsMap.delete(id); - } - - const addedItems: LibraryItem[] = []; - - // we want to merge current library items with the ones stored in the - // DB so that we don't lose any elements that for some reason aren't - // in the current editor library, which could happen when: - // - // 1. we haven't received an update deleting some elements - // (in which case it's still better to keep them in the DB lest - // it was due to a different reason) - // 2. we keep a single DB for all active editors, but the editors' - // libraries aren't synced or there's a race conditions during - // syncing - // 3. some other race condition, e.g. during init where emit updates - // for partial updates (e.g. you install a 3rd party library and - // init from DB only after — we emit events for both updates) - for (const [id, item] of update.addedItems) { - if (nextLibraryItemsMap.has(id)) { - // replace item with latest version - // TODO we could prefer the newer item instead - nextLibraryItemsMap.set(id, item); - } else { - // we want to prepend the new items with the ones that are already - // in DB to preserve the ordering we do in editor (newly added - // items are added to the beginning) - addedItems.push(item); + for (const [id] of update.deletedItems) { + nextLibraryItemsMap.delete(id); } - } - // replace existing items with their updated versions - if (update.updatedItems) { - for (const [id, item] of update.updatedItems) { - nextLibraryItemsMap.set(id, item); + const addedItems: LibraryItem[] = []; + + // we want to merge current library items with the ones stored in the + // DB so that we don't lose any elements that for some reason aren't + // in the current editor library, which could happen when: + // + // 1. we haven't received an update deleting some elements + // (in which case it's still better to keep them in the DB lest + // it was due to a different reason) + // 2. we keep a single DB for all active editors, but the editors' + // libraries aren't synced or there's a race conditions during + // syncing + // 3. some other race condition, e.g. during init where emit updates + // for partial updates (e.g. you install a 3rd party library and + // init from DB only after — we emit events for both updates) + for (const [id, item] of update.addedItems) { + if (nextLibraryItemsMap.has(id)) { + // replace item with latest version + // TODO we could prefer the newer item instead + nextLibraryItemsMap.set(id, item); + } else { + // we want to prepend the new items with the ones that are already + // in DB to preserve the ordering we do in editor (newly added + // items are added to the beginning) + addedItems.push(item); + } } - } - const nextLibraryItems = addedItems.concat( - Array.from(nextLibraryItemsMap.values()), - ); + // replace existing items with their updated versions + if (update.updatedItems) { + for (const [id, item] of update.updatedItems) { + nextLibraryItemsMap.set(id, item); + } + } - const version = getLibraryItemsHash(nextLibraryItems); + const nextLibraryItems = addedItems.concat( + Array.from(nextLibraryItemsMap.values()), + ); - if (version !== lastSavedLibraryItemsHash) { - await adapter.save({ libraryItems: nextLibraryItems }); - } + const version = getLibraryItemsHash(nextLibraryItems); - lastSavedLibraryItemsHash = version; + if (version !== lastSavedLibraryItemsHash) { + await adapter.save({ libraryItems: nextLibraryItems }); + } - return nextLibraryItems; - }); + lastSavedLibraryItemsHash = version; + + return nextLibraryItems; + }, + ); } finally { librarySaveCounter--; } @@ -854,16 +885,24 @@ export const useHandleLibrary = ( .then(async (libraryData) => { let restoredData: LibraryItems | null = null; try { + const schemaMigrationRegistry = + optsRef.current.excalidrawAPI?.getSchemaMigrationRegistry(); // if no library data to migrate, assume no migration needed // and skip persisting to new data store, as well as well // clearing the old store via `migrationAdapter.clear()` if (!libraryData) { - return AdapterTransaction.getLibraryItems(adapter, "load"); + return AdapterTransaction.getLibraryItems( + adapter, + "load", + true, + schemaMigrationRegistry, + ); } restoredData = restoreLibraryItems( libraryData.libraryItems || [], "published", + { schemaMigrationRegistry }, ); // we don't queue this operation because it's running inside @@ -871,6 +910,7 @@ export const useHandleLibrary = ( const nextItems = await persistLibraryUpdate( adapter, createLibraryUpdate([], restoredData), + schemaMigrationRegistry, ); try { await migrationAdapter.clear(); @@ -893,12 +933,23 @@ export const useHandleLibrary = ( .catch((error: any) => { console.error(`error during library migration: ${error.message}`); // as a default, load latest library from current data source - return AdapterTransaction.getLibraryItems(adapter, "load"); + return AdapterTransaction.getLibraryItems( + adapter, + "load", + true, + optsRef.current.excalidrawAPI?.getSchemaMigrationRegistry(), + ); }), ); } else { initDataPromise.resolve( - promiseTry(AdapterTransaction.getLibraryItems, adapter, "load"), + promiseTry( + AdapterTransaction.getLibraryItems, + adapter, + "load", + true, + optsRef.current.excalidrawAPI?.getSchemaMigrationRegistry(), + ), ); } @@ -960,7 +1011,11 @@ export const useHandleLibrary = ( lastSavedLibraryItemsHash !== getLibraryItemsHash(nextLibraryItems) ) { - await persistLibraryUpdate(adapter, update); + await persistLibraryUpdate( + adapter, + update, + optsRef.current.excalidrawAPI?.getSchemaMigrationRegistry(), + ); } } } catch (error: any) { diff --git a/packages/excalidraw/data/restore.ts b/packages/excalidraw/data/restore.ts index 4508767620..85fb54a3d5 100644 --- a/packages/excalidraw/data/restore.ts +++ b/packages/excalidraw/data/restore.ts @@ -84,6 +84,8 @@ import { import { migrateElements } from "./schema"; +import type { SchemaMigrationRegistry } from "./schema"; + import type { AppState, BinaryFiles, @@ -642,11 +644,15 @@ export const restoreElements = ( refreshDimensions?: boolean; repairBindings?: boolean; deleteInvisibleElements?: boolean; + schemaMigrationRegistry?: SchemaMigrationRegistry; } | undefined, ): CombineBrandsIfNeeded => { const migratedTargetElements = migrateElements( targetElements as readonly ExcalidrawElement[] | undefined | null, + { + schemaMigrationRegistry: opts?.schemaMigrationRegistry, + }, ) as readonly T[] | undefined | null; // used to detect duplicate top-level element ids @@ -966,10 +972,14 @@ export const restoreAppState = ( }; }; -const restoreLibraryItem = (libraryItem: LibraryItem) => { +const restoreLibraryItem = ( + libraryItem: LibraryItem, + opts?: { schemaMigrationRegistry?: SchemaMigrationRegistry }, +) => { const elements = restoreElements( getNonDeletedElements(libraryItem.elements), null, + { schemaMigrationRegistry: opts?.schemaMigrationRegistry }, ); return elements.length ? { ...libraryItem, elements } : null; }; @@ -977,17 +987,21 @@ const restoreLibraryItem = (libraryItem: LibraryItem) => { export const restoreLibraryItems = ( libraryItems: ImportedDataState["libraryItems"] = [], defaultStatus: LibraryItem["status"], + opts?: { schemaMigrationRegistry?: SchemaMigrationRegistry }, ) => { const restoredItems: LibraryItem[] = []; for (const item of libraryItems) { // migrate older libraries if (Array.isArray(item)) { - const restoredItem = restoreLibraryItem({ - status: defaultStatus, - elements: item, - id: randomId(), - created: Date.now(), - }); + const restoredItem = restoreLibraryItem( + { + status: defaultStatus, + elements: item, + id: randomId(), + created: Date.now(), + }, + opts, + ); if (restoredItem) { restoredItems.push(restoredItem); } @@ -996,12 +1010,15 @@ export const restoreLibraryItems = ( LibraryItem, "id" | "status" | "created" >; - const restoredItem = restoreLibraryItem({ - ..._item, - id: _item.id || randomId(), - status: _item.status || defaultStatus, - created: _item.created || Date.now(), - }); + const restoredItem = restoreLibraryItem( + { + ..._item, + id: _item.id || randomId(), + status: _item.status || defaultStatus, + created: _item.created || Date.now(), + }, + opts, + ); if (restoredItem) { restoredItems.push(restoredItem); } diff --git a/packages/excalidraw/data/schema.test.ts b/packages/excalidraw/data/schema.test.ts index ad4104f51b..f1f207c630 100644 --- a/packages/excalidraw/data/schema.test.ts +++ b/packages/excalidraw/data/schema.test.ts @@ -4,6 +4,8 @@ import { API } from "../tests/helpers/api"; import { CORE_FRAME_SCHEMA_TRACK, + createSchemaMigrationRegistry, + type SchemaPlugin, type SchemaMigration, CORE_SUPPORTED_TRACKS, migrateElements, @@ -11,6 +13,7 @@ import { SCHEMA_INITIAL_TRACK_VERSION, SCHEMA_MIGRATIONS, validateSchemaMigrations, + validateSchemaPlugins, } from "./schema"; describe("schema migration", () => { @@ -183,6 +186,42 @@ describe("schema migration", () => { ); }); + it("should reject invalid plugin metadata", () => { + const errors = validateSchemaPlugins([ + { + id: "", + migrations: [], + }, + { + id: "dup", + migrations: [], + }, + { + id: "dup", + migrations: [], + }, + { + id: "core-overwrite", + migrations: [ + { + id: "bad.core.migration", + namespace: "core", + track: CORE_FRAME_SCHEMA_TRACK, + toVersion: 2, + title: "bad", + description: "bad", + targetTypes: ["frame"], + apply: (element) => element, + }, + ], + }, + ]); + + expect(errors.join("\n")).toContain("Schema plugin id must be non-empty"); + expect(errors.join("\n")).toContain("Duplicate schema plugin id found"); + expect(errors.join("\n")).toContain("cannot declare core migrations"); + }); + it("should not depend on temporary fields during migration", () => { const frame = { ...API.createElement({ @@ -296,4 +335,43 @@ describe("schema migration", () => { ); expect(migrated[0].schemaState.tracks["host.myapp.card"]).toBe(4); }); + + it("should not run plugin migrations unless plugins are provided", () => { + const rect = API.createElement({ + type: "rectangle", + backgroundColor: "#ffd8a8", + }); + const plugin: SchemaPlugin = { + id: "myapp", + migrations: [ + { + id: "host.myapp.rect.normalize.v2", + namespace: "host.myapp", + track: "host.myapp.rectangle", + toVersion: 2, + title: "normalize rect background", + description: "plugin migration for testing", + targetTypes: ["rectangle"], + apply: (element) => + element.type === "rectangle" + ? { ...element, backgroundColor: "#12b886" } + : element, + }, + ], + }; + + const migratedWithoutPlugin = migrateElements([rect])!; + const migratedWithPlugin = migrateElements([rect], { + schemaMigrationRegistry: createSchemaMigrationRegistry([plugin]), + })!; + + expect(migratedWithoutPlugin[0].backgroundColor).toBe("#ffd8a8"); + expect( + migratedWithoutPlugin[0].schemaState.tracks["host.myapp.rectangle"], + ).toBe(undefined); + expect(migratedWithPlugin[0].backgroundColor).toBe("#12b886"); + expect( + migratedWithPlugin[0].schemaState.tracks["host.myapp.rectangle"], + ).toBe(2); + }); }); diff --git a/packages/excalidraw/data/schema.ts b/packages/excalidraw/data/schema.ts index 68d6dfedda..e8a2ac5daf 100644 --- a/packages/excalidraw/data/schema.ts +++ b/packages/excalidraw/data/schema.ts @@ -17,11 +17,16 @@ export { export type { SchemaNamespace, SchemaTrack }; /** - * Schema migration flow (per element): + * Schema migration flow: + * 0) Compile schema config from core migrations + optional host plugins. + * - validate plugin metadata + * - validate migration ordering/metadata + * - derive per-track supported versions for this registry * 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. + * 2) Iterate compiled migrations in declaration order. + * 3) For matching element types, apply only forward migrations that are + * supported by the current registry config (never re-run, never downgrade). + * 4) Stamp migrated track versions back onto each element. */ /** One migration step for a single track version bump. */ export type SchemaMigration = { @@ -42,6 +47,27 @@ export type SchemaMigration = { apply: (element: ExcalidrawElement) => ExcalidrawElement; }; +/** + * Optional host-provided migration bundle. + * Plugins are additive and may only declare host namespace migrations. + */ +export type SchemaPlugin = { + /** Stable plugin id for diagnostics. */ + id: string; + /** Host migration steps merged with core migrations into one registry. */ + migrations: readonly SchemaMigration[]; +}; + +/** Default plugin registry (intentionally empty in core). */ +export const SCHEMA_PLUGINS: readonly SchemaPlugin[] = []; + +export type SchemaMigrationRegistry = Readonly<{ + /** Fully validated core + host migrations used for this run. */ + migrations: readonly SchemaMigration[]; + /** Latest supported version for each known track in this run. */ + supportedTrackVersions: Readonly>; +}>; + export const SCHEMA_MIGRATIONS: readonly SchemaMigration[] = [ { id: "core.frame.background.normalize.v2", @@ -265,25 +291,104 @@ export const validateSchemaMigrations = ( return errors; }; -const schemaMigrationValidationErrors = - validateSchemaMigrations(SCHEMA_MIGRATIONS); -if (schemaMigrationValidationErrors.length) { - throw new Error( - `Invalid schema migration configuration:\n${schemaMigrationValidationErrors.join( - "\n", - )}`, - ); -} +export const validateSchemaPlugins = (plugins: readonly SchemaPlugin[]) => { + const errors: string[] = []; + const seenIds = new Set(); -const migrateElement = (element: ExcalidrawElement) => { - // Always migrate from a normalized per-element schema state. - let migratedElement = ensureElementSchemaState(element); + for (const plugin of plugins) { + if (!plugin.id.trim()) { + errors.push("Schema plugin id must be non-empty."); + } + if (seenIds.has(plugin.id)) { + errors.push(`Duplicate schema plugin id found: ${plugin.id}.`); + } + seenIds.add(plugin.id); - for (const migration of SCHEMA_MIGRATIONS) { - if (migration.namespace !== SCHEMA_CORE_NAMESPACE) { + for (const migration of plugin.migrations) { + if (migration.namespace === SCHEMA_CORE_NAMESPACE) { + errors.push( + `Schema plugin "${plugin.id}" cannot declare core migrations ("${migration.id}").`, + ); + } + } + } + + return errors; +}; + +const collectPluginMigrations = (plugins: readonly SchemaPlugin[]) => + plugins.flatMap((plugin) => plugin.migrations); + +/** + * Builds the registry "latest version" map: + * - core tracks come from CORE_SUPPORTED_TRACKS + * - host tracks are inferred from provided plugin migrations + */ +const getSupportedTrackVersions = ( + migrations: readonly SchemaMigration[], +): Readonly> => { + const supportedTrackVersions: Record = { + ...CORE_SUPPORTED_TRACKS, + }; + + for (const migration of migrations) { + if (migration.namespace === SCHEMA_CORE_NAMESPACE) { continue; } + const currentSupportedVersion = + supportedTrackVersions[migration.track] ?? SCHEMA_INITIAL_TRACK_VERSION; + if (migration.toVersion > currentSupportedVersion) { + supportedTrackVersions[migration.track] = migration.toVersion; + } + } + + return supportedTrackVersions; +}; + +export const createSchemaMigrationRegistry = ( + plugins: readonly SchemaPlugin[] = SCHEMA_PLUGINS, +): SchemaMigrationRegistry => { + const pluginErrors = validateSchemaPlugins(plugins); + if (pluginErrors.length) { + throw new Error( + `Invalid schema plugin configuration:\n${pluginErrors.join("\n")}`, + ); + } + + const migrations = [ + ...SCHEMA_MIGRATIONS, + ...collectPluginMigrations(plugins), + ] as const; + + const migrationErrors = validateSchemaMigrations(migrations); + if (migrationErrors.length) { + throw new Error( + `Invalid schema migration configuration:\n${migrationErrors.join("\n")}`, + ); + } + + return { + migrations, + supportedTrackVersions: getSupportedTrackVersions(migrations), + }; +}; + +const CORE_SCHEMA_MIGRATION_REGISTRY = createSchemaMigrationRegistry(); + +/** Uses cached core config by default, recompiles when plugins are provided. */ +const resolveSchemaMigrationRegistry = ( + schemaMigrationRegistry: SchemaMigrationRegistry | undefined, +) => schemaMigrationRegistry || CORE_SCHEMA_MIGRATION_REGISTRY; + +const migrateElement = ( + element: ExcalidrawElement, + schemaMigrationRegistry: SchemaMigrationRegistry, +) => { + // Always migrate from a normalized per-element schema state. + let migratedElement = ensureElementSchemaState(element); + + for (const migration of schemaMigrationRegistry.migrations) { if (!migrationMatchesElementType(migration, migratedElement)) { continue; } @@ -293,9 +398,8 @@ const migrateElement = (element: ExcalidrawElement) => { migration.track, ); const supportedTrackVersion = - CORE_SUPPORTED_TRACKS[ - migration.track as keyof typeof CORE_SUPPORTED_TRACKS - ] ?? currentTrackVersion; + schemaMigrationRegistry.supportedTrackVersions[migration.track] ?? + currentTrackVersion; // Never re-run or downgrade. if (currentTrackVersion >= migration.toVersion) { @@ -320,10 +424,19 @@ const migrateElement = (element: ExcalidrawElement) => { export const migrateElements = ( elements: readonly ExcalidrawElement[] | null | undefined, + opts?: { + schemaMigrationRegistry?: SchemaMigrationRegistry; + }, ) => { if (!elements) { return elements; } - return elements.map((element) => migrateElement(element)); + const schemaMigrationRegistry = resolveSchemaMigrationRegistry( + opts?.schemaMigrationRegistry, + ); + + return elements.map((element) => + migrateElement(element, schemaMigrationRegistry), + ); }; diff --git a/packages/excalidraw/index.tsx b/packages/excalidraw/index.tsx index f8004b4c2f..88cef7cfdd 100644 --- a/packages/excalidraw/index.tsx +++ b/packages/excalidraw/index.tsx @@ -56,6 +56,7 @@ const ExcalidrawBase = (props: ExcalidrawProps) => { aiEnabled, showDeprecatedFonts, renderScrollbars, + schemaPlugins, } = props; const canvasActions = props.UIOptions?.canvasActions; @@ -149,6 +150,7 @@ const ExcalidrawBase = (props: ExcalidrawProps) => { aiEnabled={aiEnabled !== false} showDeprecatedFonts={showDeprecatedFonts} renderScrollbars={renderScrollbars} + schemaPlugins={schemaPlugins} > {children} diff --git a/packages/excalidraw/tests/data/restore.test.ts b/packages/excalidraw/tests/data/restore.test.ts index 764b13ca9c..d3d526acad 100644 --- a/packages/excalidraw/tests/data/restore.test.ts +++ b/packages/excalidraw/tests/data/restore.test.ts @@ -24,8 +24,10 @@ import type { NormalizedZoomValue } from "@excalidraw/excalidraw/types"; import { API } from "../helpers/api"; import * as restore from "../../data/restore"; +import { createSchemaMigrationRegistry } from "../../data/schema"; import { getDefaultAppState } from "../../appState"; +import type { SchemaPlugin } from "../../data/schema"; import type { ImportedDataState } from "../../data/types"; describe("restoreElements", () => { @@ -153,6 +155,42 @@ describe("restoreElements", () => { ).toBe(DEFAULT_ELEMENT_PROPS.backgroundColor); }); + it("should apply schema plugins on restore boundaries when provided", () => { + const rect = API.createElement({ + type: "rectangle", + backgroundColor: "#ffd8a8", + }); + const plugin: SchemaPlugin = { + id: "myapp", + migrations: [ + { + id: "host.myapp.rect.normalize.v2", + namespace: "host.myapp", + track: "host.myapp.rectangle", + toVersion: 2, + title: "normalize rectangle background", + description: "plugin migration for restore test", + targetTypes: ["rectangle"], + apply: (element) => + element.type === "rectangle" + ? { ...element, backgroundColor: "#12b886" } + : element, + }, + ], + }; + + const restoredWithoutPlugin = restore.restoreElements([rect], null); + const restoredWithPlugin = restore.restoreElements([rect], null, { + schemaMigrationRegistry: createSchemaMigrationRegistry([plugin]), + }); + + expect(restoredWithoutPlugin[0].backgroundColor).toBe("#ffd8a8"); + expect(restoredWithPlugin[0].backgroundColor).toBe("#12b886"); + expect( + restoredWithPlugin[0].schemaState.tracks["host.myapp.rectangle"], + ).toBe(2); + }); + it("should restore text element correctly passing value for each attribute", () => { const textElement = API.createElement({ type: "text", diff --git a/packages/excalidraw/types.ts b/packages/excalidraw/types.ts index 6ce4ae9267..1d66f1cc7a 100644 --- a/packages/excalidraw/types.ts +++ b/packages/excalidraw/types.ts @@ -58,6 +58,7 @@ import type { FileSystemHandle } from "./data/filesystem"; import type { ContextMenuItems } from "./components/ContextMenu"; import type { SnapLine } from "./snapping"; import type { ImportedDataState } from "./data/types"; +import type { SchemaMigrationRegistry, SchemaPlugin } from "./data/schema"; import type { Language } from "./i18n"; import type { isOverScrollBars } from "./scene/scrollbars"; @@ -640,6 +641,11 @@ export interface ExcalidrawProps { aiEnabled?: boolean; showDeprecatedFonts?: boolean; renderScrollbars?: boolean; + /** + * Optional host-provided schema migration plugins. + * Applied on restore/import boundaries when provided. + */ + schemaPlugins?: readonly SchemaPlugin[]; } export type SceneData = { @@ -758,6 +764,7 @@ export type AppClassProperties = { getEditorUIOffsets: App["getEditorUIOffsets"]; visibleElements: App["visibleElements"]; excalidrawContainerValue: App["excalidrawContainerValue"]; + getSchemaMigrationRegistry: () => SchemaMigrationRegistry; onPointerUpEmitter: App["onPointerUpEmitter"]; updateEditorAtom: App["updateEditorAtom"]; @@ -867,6 +874,7 @@ export interface ExcalidrawImperativeAPI { resetCursor: InstanceType["resetCursor"]; toggleSidebar: InstanceType["toggleSidebar"]; getEditorInterface: () => EditorInterface; + getSchemaMigrationRegistry: () => SchemaMigrationRegistry; /** * Disables rendering of frames (including element clipping), but currently * the frames are still interactive in edit mode. As such, this API should be