feat: wire schema migration plugins end-to-end

This commit is contained in:
Ryan Di
2026-03-25 19:19:13 +11:00
parent b871d4ceb3
commit 91981159d2
11 changed files with 460 additions and 99 deletions
+5 -1
View File
@@ -271,7 +271,11 @@ export const actionLoadScene = register({
elements: loadedElements, elements: loadedElements,
appState: loadedAppState, appState: loadedAppState,
files, files,
} = await loadFromJSON(appState, elements); } = await loadFromJSON(
appState,
elements,
app.getSchemaMigrationRegistry(),
);
return { return {
elements: loadedElements, elements: loadedElements,
appState: loadedAppState, appState: loadedAppState,
+26 -1
View File
@@ -354,6 +354,7 @@ import {
import { exportCanvas, loadFromBlob } from "../data"; import { exportCanvas, loadFromBlob } from "../data";
import Library, { distributeLibraryItemsOnSquareGrid } from "../data/library"; import Library, { distributeLibraryItemsOnSquareGrid } from "../data/library";
import { restoreAppState, restoreElements } from "../data/restore"; import { restoreAppState, restoreElements } from "../data/restore";
import { createSchemaMigrationRegistry } from "../data/schema";
import { getCenter, getDistance } from "../gesture"; import { getCenter, getDistance } from "../gesture";
import { History } from "../history"; import { History } from "../history";
import { defaultLang, getLanguage, languages, setLanguage, t } from "../i18n"; import { defaultLang, getLanguage, languages, setLanguage, t } from "../i18n";
@@ -460,6 +461,7 @@ import type {
import type { ClipboardData, PastedMixedContent } from "../clipboard"; import type { ClipboardData, PastedMixedContent } from "../clipboard";
import type { ExportedElements } from "../data"; import type { ExportedElements } from "../data";
import type { SchemaMigrationRegistry } from "../data/schema";
import type { ContextMenuItems } from "./ContextMenu"; import type { ContextMenuItems } from "./ContextMenu";
import type { FileSystemHandle } from "../data/filesystem"; import type { FileSystemHandle } from "../data/filesystem";
@@ -606,6 +608,7 @@ class App extends React.Component<AppProps, AppState> {
public fonts: Fonts; public fonts: Fonts;
public renderer: Renderer; public renderer: Renderer;
public visibleElements: readonly NonDeletedExcalidrawElement[]; public visibleElements: readonly NonDeletedExcalidrawElement[];
private schemaMigrationRegistry: SchemaMigrationRegistry;
private resizeObserver: ResizeObserver | undefined; private resizeObserver: ResizeObserver | undefined;
public library: AppClassProperties["library"]; public library: AppClassProperties["library"];
public libraryItemsFromStorage: LibraryItems | undefined; public libraryItemsFromStorage: LibraryItems | undefined;
@@ -722,6 +725,9 @@ class App extends React.Component<AppProps, AppState> {
this.stylesPanelMode = deriveStylesPanelMode(this.editorInterface); this.stylesPanelMode = deriveStylesPanelMode(this.editorInterface);
this.id = nanoid(); this.id = nanoid();
this.schemaMigrationRegistry = createSchemaMigrationRegistry(
props.schemaPlugins,
);
this.library = new Library(this); this.library = new Library(this);
this.actionManager = new ActionManager( this.actionManager = new ActionManager(
this.syncActionResult, this.syncActionResult,
@@ -768,6 +774,7 @@ class App extends React.Component<AppProps, AppState> {
setCursor: this.setCursor, setCursor: this.setCursor,
resetCursor: this.resetCursor, resetCursor: this.resetCursor,
getEditorInterface: () => this.editorInterface, getEditorInterface: () => this.editorInterface,
getSchemaMigrationRegistry: this.getSchemaMigrationRegistry,
updateFrameRendering: this.updateFrameRendering, updateFrameRendering: this.updateFrameRendering,
toggleSidebar: this.toggleSidebar, toggleSidebar: this.toggleSidebar,
onChange: (cb) => this.onChangeEmitter.on(cb), onChange: (cb) => this.onChangeEmitter.on(cb),
@@ -2320,6 +2327,10 @@ class App extends React.Component<AppProps, AppState> {
return this.scene.getNonDeletedElements(); return this.scene.getNonDeletedElements();
}; };
public getSchemaMigrationRegistry = () => {
return this.schemaMigrationRegistry;
};
public onInsertElements = (elements: readonly ExcalidrawElement[]) => { public onInsertElements = (elements: readonly ExcalidrawElement[]) => {
this.addElementsFromPasteOrLibrary({ this.addElementsFromPasteOrLibrary({
elements, elements,
@@ -2808,6 +2819,7 @@ class App extends React.Component<AppProps, AppState> {
const restoredElements = restoreElements(initialData?.elements, null, { const restoredElements = restoreElements(initialData?.elements, null, {
repairBindings: true, repairBindings: true,
deleteInvisibleElements: true, deleteInvisibleElements: true,
schemaMigrationRegistry: this.schemaMigrationRegistry,
}); });
let restoredAppState = restoreAppState(initialData?.appState, null); let restoredAppState = restoreAppState(initialData?.appState, null);
const activeTool = restoredAppState.activeTool; const activeTool = restoredAppState.activeTool;
@@ -3219,6 +3231,12 @@ class App extends React.Component<AppProps, AppState> {
} }
componentDidUpdate(prevProps: AppProps, prevState: AppState) { componentDidUpdate(prevProps: AppProps, prevState: AppState) {
if (prevProps.schemaPlugins !== this.props.schemaPlugins) {
this.schemaMigrationRegistry = createSchemaMigrationRegistry(
this.props.schemaPlugins,
);
}
this.updateEmbeddables(); this.updateEmbeddables();
const elements = this.scene.getElementsIncludingDeleted(); const elements = this.scene.getElementsIncludingDeleted();
const elementsMap = this.scene.getElementsMapIncludingDeleted(); const elementsMap = this.scene.getElementsMapIncludingDeleted();
@@ -3722,6 +3740,7 @@ class App extends React.Component<AppProps, AppState> {
}) => { }) => {
const elements = restoreElements(opts.elements, null, { const elements = restoreElements(opts.elements, null, {
deleteInvisibleElements: true, deleteInvisibleElements: true,
schemaMigrationRegistry: this.schemaMigrationRegistry,
}); });
const [minX, minY, maxX, maxY] = getCommonBounds(elements); const [minX, minY, maxX, maxY] = getCommonBounds(elements);
@@ -11428,6 +11447,7 @@ class App extends React.Component<AppProps, AppState> {
this.state, this.state,
this.scene.getElementsIncludingDeleted(), this.scene.getElementsIncludingDeleted(),
fileHandle, fileHandle,
this.schemaMigrationRegistry,
); );
this.syncActionResult({ this.syncActionResult({
...scene, ...scene,
@@ -11474,7 +11494,11 @@ class App extends React.Component<AppProps, AppState> {
); );
// legacy library dataTransfer format // legacy library dataTransfer format
} else if (excalidrawLibrary_data) { } else if (excalidrawLibrary_data) {
libraryItems = parseLibraryJSON(excalidrawLibrary_data); libraryItems = parseLibraryJSON(
excalidrawLibrary_data,
"unpublished",
this.schemaMigrationRegistry,
);
} }
if (libraryItems?.length) { if (libraryItems?.length) {
libraryItems = libraryItems.map((item) => ({ libraryItems = libraryItems.map((item) => ({
@@ -11544,6 +11568,7 @@ class App extends React.Component<AppProps, AppState> {
this.state, this.state,
elements, elements,
fileHandle, fileHandle,
this.schemaMigrationRegistry,
); );
} catch (error: any) { } catch (error: any) {
const imageSceneDataError = error instanceof ImageSceneDataError; const imageSceneDataError = error instanceof ImageSceneDataError;
+15 -2
View File
@@ -28,6 +28,7 @@ import {
import type { AppState, DataURL, LibraryItem } from "../types"; import type { AppState, DataURL, LibraryItem } from "../types";
import type { FileSystemHandle } from "browser-fs-access"; import type { FileSystemHandle } from "browser-fs-access";
import type { SchemaMigrationRegistry } from "./schema";
import type { ImportedLibraryData } from "./types"; import type { ImportedLibraryData } from "./types";
const parseFileContents = async (blob: Blob | File): Promise<string> => { const parseFileContents = async (blob: Blob | File): Promise<string> => {
@@ -141,6 +142,7 @@ export const loadSceneOrLibraryFromBlob = async (
localElements: readonly ExcalidrawElement[] | null, localElements: readonly ExcalidrawElement[] | null,
/** FileSystemHandle. Defaults to `blob.handle` if defined, otherwise null. */ /** FileSystemHandle. Defaults to `blob.handle` if defined, otherwise null. */
fileHandle?: FileSystemHandle | null, fileHandle?: FileSystemHandle | null,
schemaMigrationRegistry?: SchemaMigrationRegistry,
) => { ) => {
const contents = await parseFileContents(blob); const contents = await parseFileContents(blob);
let data; let data;
@@ -163,6 +165,7 @@ export const loadSceneOrLibraryFromBlob = async (
elements: restoreElements(data.elements, localElements, { elements: restoreElements(data.elements, localElements, {
repairBindings: true, repairBindings: true,
deleteInvisibleElements: true, deleteInvisibleElements: true,
schemaMigrationRegistry,
}), }),
appState: restoreAppState( appState: restoreAppState(
{ {
@@ -200,12 +203,14 @@ export const loadFromBlob = async (
localElements: readonly ExcalidrawElement[] | null, localElements: readonly ExcalidrawElement[] | null,
/** FileSystemHandle. Defaults to `blob.handle` if defined, otherwise null. */ /** FileSystemHandle. Defaults to `blob.handle` if defined, otherwise null. */
fileHandle?: FileSystemHandle | null, fileHandle?: FileSystemHandle | null,
schemaMigrationRegistry?: SchemaMigrationRegistry,
) => { ) => {
const ret = await loadSceneOrLibraryFromBlob( const ret = await loadSceneOrLibraryFromBlob(
blob, blob,
localAppState, localAppState,
localElements, localElements,
fileHandle, fileHandle,
schemaMigrationRegistry,
); );
if (ret.type !== MIME_TYPES.excalidraw) { if (ret.type !== MIME_TYPES.excalidraw) {
throw new Error("Error: invalid file"); throw new Error("Error: invalid file");
@@ -216,20 +221,28 @@ export const loadFromBlob = async (
export const parseLibraryJSON = ( export const parseLibraryJSON = (
json: string, json: string,
defaultStatus: LibraryItem["status"] = "unpublished", defaultStatus: LibraryItem["status"] = "unpublished",
schemaMigrationRegistry?: SchemaMigrationRegistry,
) => { ) => {
const data: ImportedLibraryData | undefined = JSON.parse(json); const data: ImportedLibraryData | undefined = JSON.parse(json);
if (!isValidLibrary(data)) { if (!isValidLibrary(data)) {
throw new Error("Invalid library"); throw new Error("Invalid library");
} }
const libraryItems = data.libraryItems || data.library; const libraryItems = data.libraryItems || data.library;
return restoreLibraryItems(libraryItems, defaultStatus); return restoreLibraryItems(libraryItems, defaultStatus, {
schemaMigrationRegistry,
});
}; };
export const loadLibraryFromBlob = async ( export const loadLibraryFromBlob = async (
blob: Blob, blob: Blob,
defaultStatus: LibraryItem["status"] = "unpublished", defaultStatus: LibraryItem["status"] = "unpublished",
schemaMigrationRegistry?: SchemaMigrationRegistry,
) => { ) => {
return parseLibraryJSON(await parseFileContents(blob), defaultStatus); return parseLibraryJSON(
await parseFileContents(blob),
defaultStatus,
schemaMigrationRegistry,
);
}; };
export const canvasToBlob = async ( export const canvasToBlob = async (
+9 -1
View File
@@ -14,6 +14,7 @@ import { isImageFileHandle, loadFromBlob } from "./blob";
import { fileOpen, fileSave } from "./filesystem"; import { fileOpen, fileSave } from "./filesystem";
import type { AppState, BinaryFiles, LibraryItems } from "../types"; import type { AppState, BinaryFiles, LibraryItems } from "../types";
import type { SchemaMigrationRegistry } from "./schema";
import type { import type {
ExportedDataState, ExportedDataState,
ImportedDataState, ImportedDataState,
@@ -93,6 +94,7 @@ export const saveAsJSON = async (
export const loadFromJSON = async ( export const loadFromJSON = async (
localAppState: AppState, localAppState: AppState,
localElements: readonly ExcalidrawElement[] | null, localElements: readonly ExcalidrawElement[] | null,
schemaMigrationRegistry?: SchemaMigrationRegistry,
) => { ) => {
const file = await fileOpen({ const file = await fileOpen({
description: "Excalidraw files", description: "Excalidraw files",
@@ -100,7 +102,13 @@ export const loadFromJSON = async (
// gets resolved. Else, iOS users cannot open `.excalidraw` files. // gets resolved. Else, iOS users cannot open `.excalidraw` files.
// extensions: ["json", "excalidraw", "png", "svg"], // extensions: ["json", "excalidraw", "png", "svg"],
}); });
return loadFromBlob(file, localAppState, localElements, file.handle); return loadFromBlob(
file,
localAppState,
localElements,
file.handle,
schemaMigrationRegistry,
);
}; };
export const isValidExcalidrawData = (data?: { export const isValidExcalidrawData = (data?: {
+114 -59
View File
@@ -35,6 +35,7 @@ import { loadLibraryFromBlob } from "./blob";
import { restoreLibraryItems } from "./restore"; import { restoreLibraryItems } from "./restore";
import type App from "../components/App"; import type App from "../components/App";
import type { SchemaMigrationRegistry } from "./schema";
import type { import type {
LibraryItems, LibraryItems,
@@ -316,9 +317,15 @@ class Library {
let nextItems; let nextItems;
if (source instanceof Blob) { if (source instanceof Blob) {
nextItems = await loadLibraryFromBlob(source, defaultStatus); nextItems = await loadLibraryFromBlob(
source,
defaultStatus,
this.app.getSchemaMigrationRegistry(),
);
} else { } else {
nextItems = restoreLibraryItems(source, defaultStatus); nextItems = restoreLibraryItems(source, defaultStatus, {
schemaMigrationRegistry: this.app.getSchemaMigrationRegistry(),
});
} }
if ( if (
!prompt || !prompt ||
@@ -551,12 +558,17 @@ class AdapterTransaction {
adapter: LibraryPersistenceAdapter, adapter: LibraryPersistenceAdapter,
source: LibraryAdatapterSource, source: LibraryAdatapterSource,
_queue = true, _queue = true,
schemaMigrationRegistry?: SchemaMigrationRegistry,
): Promise<LibraryItems> { ): Promise<LibraryItems> {
const task = () => const task = () =>
new Promise<LibraryItems>(async (resolve, reject) => { new Promise<LibraryItems>(async (resolve, reject) => {
try { try {
const data = await adapter.load({ source }); const data = await adapter.load({ source });
resolve(restoreLibraryItems(data?.libraryItems || [], "published")); resolve(
restoreLibraryItems(data?.libraryItems || [], "published", {
schemaMigrationRegistry,
}),
);
} catch (error: any) { } catch (error: any) {
reject(error); reject(error);
} }
@@ -571,22 +583,36 @@ class AdapterTransaction {
static run = async <T>( static run = async <T>(
adapter: LibraryPersistenceAdapter, adapter: LibraryPersistenceAdapter,
schemaMigrationRegistry: SchemaMigrationRegistry | undefined,
fn: (transaction: AdapterTransaction) => Promise<T>, fn: (transaction: AdapterTransaction) => Promise<T>,
) => { ) => {
const transaction = new AdapterTransaction(adapter); const transaction = new AdapterTransaction(
adapter,
schemaMigrationRegistry,
);
return AdapterTransaction.queue.push(() => fn(transaction)); return AdapterTransaction.queue.push(() => fn(transaction));
}; };
// ------------------ // ------------------
private adapter: LibraryPersistenceAdapter; private adapter: LibraryPersistenceAdapter;
private schemaMigrationRegistry: SchemaMigrationRegistry | undefined;
constructor(adapter: LibraryPersistenceAdapter) { constructor(
adapter: LibraryPersistenceAdapter,
schemaMigrationRegistry: SchemaMigrationRegistry | undefined,
) {
this.adapter = adapter; this.adapter = adapter;
this.schemaMigrationRegistry = schemaMigrationRegistry;
} }
getLibraryItems(source: LibraryAdatapterSource) { 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 ( const persistLibraryUpdate = async (
adapter: LibraryPersistenceAdapter, adapter: LibraryPersistenceAdapter,
update: LibraryUpdate, update: LibraryUpdate,
schemaMigrationRegistry: SchemaMigrationRegistry | undefined,
): Promise<LibraryItems> => { ): Promise<LibraryItems> => {
try { try {
librarySaveCounter++; librarySaveCounter++;
return await AdapterTransaction.run(adapter, async (transaction) => { return await AdapterTransaction.run(
const nextLibraryItemsMap = arrayToMap( adapter,
await transaction.getLibraryItems("save"), schemaMigrationRegistry,
); async (transaction) => {
const nextLibraryItemsMap = arrayToMap(
await transaction.getLibraryItems("save"),
);
for (const [id] of update.deletedItems) { for (const [id] of update.deletedItems) {
nextLibraryItemsMap.delete(id); 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);
} }
}
// replace existing items with their updated versions const addedItems: LibraryItem[] = [];
if (update.updatedItems) {
for (const [id, item] of update.updatedItems) { // we want to merge current library items with the ones stored in the
nextLibraryItemsMap.set(id, item); // 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( // replace existing items with their updated versions
Array.from(nextLibraryItemsMap.values()), 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) { const version = getLibraryItemsHash(nextLibraryItems);
await adapter.save({ libraryItems: nextLibraryItems });
}
lastSavedLibraryItemsHash = version; if (version !== lastSavedLibraryItemsHash) {
await adapter.save({ libraryItems: nextLibraryItems });
}
return nextLibraryItems; lastSavedLibraryItemsHash = version;
});
return nextLibraryItems;
},
);
} finally { } finally {
librarySaveCounter--; librarySaveCounter--;
} }
@@ -854,16 +885,24 @@ export const useHandleLibrary = (
.then(async (libraryData) => { .then(async (libraryData) => {
let restoredData: LibraryItems | null = null; let restoredData: LibraryItems | null = null;
try { try {
const schemaMigrationRegistry =
optsRef.current.excalidrawAPI?.getSchemaMigrationRegistry();
// if no library data to migrate, assume no migration needed // if no library data to migrate, assume no migration needed
// and skip persisting to new data store, as well as well // and skip persisting to new data store, as well as well
// clearing the old store via `migrationAdapter.clear()` // clearing the old store via `migrationAdapter.clear()`
if (!libraryData) { if (!libraryData) {
return AdapterTransaction.getLibraryItems(adapter, "load"); return AdapterTransaction.getLibraryItems(
adapter,
"load",
true,
schemaMigrationRegistry,
);
} }
restoredData = restoreLibraryItems( restoredData = restoreLibraryItems(
libraryData.libraryItems || [], libraryData.libraryItems || [],
"published", "published",
{ schemaMigrationRegistry },
); );
// we don't queue this operation because it's running inside // we don't queue this operation because it's running inside
@@ -871,6 +910,7 @@ export const useHandleLibrary = (
const nextItems = await persistLibraryUpdate( const nextItems = await persistLibraryUpdate(
adapter, adapter,
createLibraryUpdate([], restoredData), createLibraryUpdate([], restoredData),
schemaMigrationRegistry,
); );
try { try {
await migrationAdapter.clear(); await migrationAdapter.clear();
@@ -893,12 +933,23 @@ export const useHandleLibrary = (
.catch((error: any) => { .catch((error: any) => {
console.error(`error during library migration: ${error.message}`); console.error(`error during library migration: ${error.message}`);
// as a default, load latest library from current data source // 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 { } else {
initDataPromise.resolve( 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 !== lastSavedLibraryItemsHash !==
getLibraryItemsHash(nextLibraryItems) getLibraryItemsHash(nextLibraryItems)
) { ) {
await persistLibraryUpdate(adapter, update); await persistLibraryUpdate(
adapter,
update,
optsRef.current.excalidrawAPI?.getSchemaMigrationRegistry(),
);
} }
} }
} catch (error: any) { } catch (error: any) {
+30 -13
View File
@@ -84,6 +84,8 @@ import {
import { migrateElements } from "./schema"; import { migrateElements } from "./schema";
import type { SchemaMigrationRegistry } from "./schema";
import type { import type {
AppState, AppState,
BinaryFiles, BinaryFiles,
@@ -642,11 +644,15 @@ export const restoreElements = <T extends ExcalidrawElement>(
refreshDimensions?: boolean; refreshDimensions?: boolean;
repairBindings?: boolean; repairBindings?: boolean;
deleteInvisibleElements?: boolean; deleteInvisibleElements?: boolean;
schemaMigrationRegistry?: SchemaMigrationRegistry;
} }
| undefined, | undefined,
): CombineBrandsIfNeeded<T, OrderedExcalidrawElement> => { ): CombineBrandsIfNeeded<T, OrderedExcalidrawElement> => {
const migratedTargetElements = migrateElements( const migratedTargetElements = migrateElements(
targetElements as readonly ExcalidrawElement[] | undefined | null, targetElements as readonly ExcalidrawElement[] | undefined | null,
{
schemaMigrationRegistry: opts?.schemaMigrationRegistry,
},
) as readonly T[] | undefined | null; ) as readonly T[] | undefined | null;
// used to detect duplicate top-level element ids // 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( const elements = restoreElements(
getNonDeletedElements(libraryItem.elements), getNonDeletedElements(libraryItem.elements),
null, null,
{ schemaMigrationRegistry: opts?.schemaMigrationRegistry },
); );
return elements.length ? { ...libraryItem, elements } : null; return elements.length ? { ...libraryItem, elements } : null;
}; };
@@ -977,17 +987,21 @@ const restoreLibraryItem = (libraryItem: LibraryItem) => {
export const restoreLibraryItems = ( export const restoreLibraryItems = (
libraryItems: ImportedDataState["libraryItems"] = [], libraryItems: ImportedDataState["libraryItems"] = [],
defaultStatus: LibraryItem["status"], defaultStatus: LibraryItem["status"],
opts?: { schemaMigrationRegistry?: SchemaMigrationRegistry },
) => { ) => {
const restoredItems: LibraryItem[] = []; const restoredItems: LibraryItem[] = [];
for (const item of libraryItems) { for (const item of libraryItems) {
// migrate older libraries // migrate older libraries
if (Array.isArray(item)) { if (Array.isArray(item)) {
const restoredItem = restoreLibraryItem({ const restoredItem = restoreLibraryItem(
status: defaultStatus, {
elements: item, status: defaultStatus,
id: randomId(), elements: item,
created: Date.now(), id: randomId(),
}); created: Date.now(),
},
opts,
);
if (restoredItem) { if (restoredItem) {
restoredItems.push(restoredItem); restoredItems.push(restoredItem);
} }
@@ -996,12 +1010,15 @@ export const restoreLibraryItems = (
LibraryItem, LibraryItem,
"id" | "status" | "created" "id" | "status" | "created"
>; >;
const restoredItem = restoreLibraryItem({ const restoredItem = restoreLibraryItem(
..._item, {
id: _item.id || randomId(), ..._item,
status: _item.status || defaultStatus, id: _item.id || randomId(),
created: _item.created || Date.now(), status: _item.status || defaultStatus,
}); created: _item.created || Date.now(),
},
opts,
);
if (restoredItem) { if (restoredItem) {
restoredItems.push(restoredItem); restoredItems.push(restoredItem);
} }
+78
View File
@@ -4,6 +4,8 @@ import { API } from "../tests/helpers/api";
import { import {
CORE_FRAME_SCHEMA_TRACK, CORE_FRAME_SCHEMA_TRACK,
createSchemaMigrationRegistry,
type SchemaPlugin,
type SchemaMigration, type SchemaMigration,
CORE_SUPPORTED_TRACKS, CORE_SUPPORTED_TRACKS,
migrateElements, migrateElements,
@@ -11,6 +13,7 @@ import {
SCHEMA_INITIAL_TRACK_VERSION, SCHEMA_INITIAL_TRACK_VERSION,
SCHEMA_MIGRATIONS, SCHEMA_MIGRATIONS,
validateSchemaMigrations, validateSchemaMigrations,
validateSchemaPlugins,
} from "./schema"; } from "./schema";
describe("schema migration", () => { 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", () => { it("should not depend on temporary fields during migration", () => {
const frame = { const frame = {
...API.createElement({ ...API.createElement({
@@ -296,4 +335,43 @@ describe("schema migration", () => {
); );
expect(migrated[0].schemaState.tracks["host.myapp.card"]).toBe(4); 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);
});
}); });
+135 -22
View File
@@ -17,11 +17,16 @@ export {
export type { SchemaNamespace, SchemaTrack }; 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). * 1) Normalize element.schemaState.tracks (invalid/missing -> initial track version).
* 2) Iterate declared migrations in order. * 2) Iterate compiled migrations in declaration order.
* 3) For matching element types, apply only forward migrations supported by current app. * 3) For matching element types, apply only forward migrations that are
* 4) Persist migrated track versions back onto the element. * 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. */ /** One migration step for a single track version bump. */
export type SchemaMigration = { export type SchemaMigration = {
@@ -42,6 +47,27 @@ export type SchemaMigration = {
apply: (element: ExcalidrawElement) => ExcalidrawElement; 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<Record<string, number>>;
}>;
export const SCHEMA_MIGRATIONS: readonly SchemaMigration[] = [ export const SCHEMA_MIGRATIONS: readonly SchemaMigration[] = [
{ {
id: "core.frame.background.normalize.v2", id: "core.frame.background.normalize.v2",
@@ -265,25 +291,104 @@ export const validateSchemaMigrations = (
return errors; return errors;
}; };
const schemaMigrationValidationErrors = export const validateSchemaPlugins = (plugins: readonly SchemaPlugin[]) => {
validateSchemaMigrations(SCHEMA_MIGRATIONS); const errors: string[] = [];
if (schemaMigrationValidationErrors.length) { const seenIds = new Set<string>();
throw new Error(
`Invalid schema migration configuration:\n${schemaMigrationValidationErrors.join(
"\n",
)}`,
);
}
const migrateElement = (element: ExcalidrawElement) => { for (const plugin of plugins) {
// Always migrate from a normalized per-element schema state. if (!plugin.id.trim()) {
let migratedElement = ensureElementSchemaState(element); 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) { for (const migration of plugin.migrations) {
if (migration.namespace !== SCHEMA_CORE_NAMESPACE) { 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<Record<string, number>> => {
const supportedTrackVersions: Record<string, number> = {
...CORE_SUPPORTED_TRACKS,
};
for (const migration of migrations) {
if (migration.namespace === SCHEMA_CORE_NAMESPACE) {
continue; 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)) { if (!migrationMatchesElementType(migration, migratedElement)) {
continue; continue;
} }
@@ -293,9 +398,8 @@ const migrateElement = (element: ExcalidrawElement) => {
migration.track, migration.track,
); );
const supportedTrackVersion = const supportedTrackVersion =
CORE_SUPPORTED_TRACKS[ schemaMigrationRegistry.supportedTrackVersions[migration.track] ??
migration.track as keyof typeof CORE_SUPPORTED_TRACKS currentTrackVersion;
] ?? currentTrackVersion;
// Never re-run or downgrade. // Never re-run or downgrade.
if (currentTrackVersion >= migration.toVersion) { if (currentTrackVersion >= migration.toVersion) {
@@ -320,10 +424,19 @@ const migrateElement = (element: ExcalidrawElement) => {
export const migrateElements = ( export const migrateElements = (
elements: readonly ExcalidrawElement[] | null | undefined, elements: readonly ExcalidrawElement[] | null | undefined,
opts?: {
schemaMigrationRegistry?: SchemaMigrationRegistry;
},
) => { ) => {
if (!elements) { if (!elements) {
return elements; return elements;
} }
return elements.map((element) => migrateElement(element)); const schemaMigrationRegistry = resolveSchemaMigrationRegistry(
opts?.schemaMigrationRegistry,
);
return elements.map((element) =>
migrateElement(element, schemaMigrationRegistry),
);
}; };
+2
View File
@@ -56,6 +56,7 @@ const ExcalidrawBase = (props: ExcalidrawProps) => {
aiEnabled, aiEnabled,
showDeprecatedFonts, showDeprecatedFonts,
renderScrollbars, renderScrollbars,
schemaPlugins,
} = props; } = props;
const canvasActions = props.UIOptions?.canvasActions; const canvasActions = props.UIOptions?.canvasActions;
@@ -149,6 +150,7 @@ const ExcalidrawBase = (props: ExcalidrawProps) => {
aiEnabled={aiEnabled !== false} aiEnabled={aiEnabled !== false}
showDeprecatedFonts={showDeprecatedFonts} showDeprecatedFonts={showDeprecatedFonts}
renderScrollbars={renderScrollbars} renderScrollbars={renderScrollbars}
schemaPlugins={schemaPlugins}
> >
{children} {children}
</App> </App>
@@ -24,8 +24,10 @@ import type { NormalizedZoomValue } from "@excalidraw/excalidraw/types";
import { API } from "../helpers/api"; import { API } from "../helpers/api";
import * as restore from "../../data/restore"; import * as restore from "../../data/restore";
import { createSchemaMigrationRegistry } from "../../data/schema";
import { getDefaultAppState } from "../../appState"; import { getDefaultAppState } from "../../appState";
import type { SchemaPlugin } from "../../data/schema";
import type { ImportedDataState } from "../../data/types"; import type { ImportedDataState } from "../../data/types";
describe("restoreElements", () => { describe("restoreElements", () => {
@@ -153,6 +155,42 @@ describe("restoreElements", () => {
).toBe(DEFAULT_ELEMENT_PROPS.backgroundColor); ).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", () => { it("should restore text element correctly passing value for each attribute", () => {
const textElement = API.createElement({ const textElement = API.createElement({
type: "text", type: "text",
+8
View File
@@ -58,6 +58,7 @@ import type { FileSystemHandle } from "./data/filesystem";
import type { ContextMenuItems } from "./components/ContextMenu"; import type { ContextMenuItems } from "./components/ContextMenu";
import type { SnapLine } from "./snapping"; import type { SnapLine } from "./snapping";
import type { ImportedDataState } from "./data/types"; import type { ImportedDataState } from "./data/types";
import type { SchemaMigrationRegistry, SchemaPlugin } from "./data/schema";
import type { Language } from "./i18n"; import type { Language } from "./i18n";
import type { isOverScrollBars } from "./scene/scrollbars"; import type { isOverScrollBars } from "./scene/scrollbars";
@@ -640,6 +641,11 @@ export interface ExcalidrawProps {
aiEnabled?: boolean; aiEnabled?: boolean;
showDeprecatedFonts?: boolean; showDeprecatedFonts?: boolean;
renderScrollbars?: boolean; renderScrollbars?: boolean;
/**
* Optional host-provided schema migration plugins.
* Applied on restore/import boundaries when provided.
*/
schemaPlugins?: readonly SchemaPlugin[];
} }
export type SceneData = { export type SceneData = {
@@ -758,6 +764,7 @@ export type AppClassProperties = {
getEditorUIOffsets: App["getEditorUIOffsets"]; getEditorUIOffsets: App["getEditorUIOffsets"];
visibleElements: App["visibleElements"]; visibleElements: App["visibleElements"];
excalidrawContainerValue: App["excalidrawContainerValue"]; excalidrawContainerValue: App["excalidrawContainerValue"];
getSchemaMigrationRegistry: () => SchemaMigrationRegistry;
onPointerUpEmitter: App["onPointerUpEmitter"]; onPointerUpEmitter: App["onPointerUpEmitter"];
updateEditorAtom: App["updateEditorAtom"]; updateEditorAtom: App["updateEditorAtom"];
@@ -867,6 +874,7 @@ export interface ExcalidrawImperativeAPI {
resetCursor: InstanceType<typeof App>["resetCursor"]; resetCursor: InstanceType<typeof App>["resetCursor"];
toggleSidebar: InstanceType<typeof App>["toggleSidebar"]; toggleSidebar: InstanceType<typeof App>["toggleSidebar"];
getEditorInterface: () => EditorInterface; getEditorInterface: () => EditorInterface;
getSchemaMigrationRegistry: () => SchemaMigrationRegistry;
/** /**
* Disables rendering of frames (including element clipping), but currently * Disables rendering of frames (including element clipping), but currently
* the frames are still interactive in edit mode. As such, this API should be * the frames are still interactive in edit mode. As such, this API should be