feat: wire schema migration plugins end-to-end
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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<AppProps, AppState> {
|
||||
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<AppProps, AppState> {
|
||||
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<AppProps, AppState> {
|
||||
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<AppProps, AppState> {
|
||||
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<AppProps, AppState> {
|
||||
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<AppProps, AppState> {
|
||||
}
|
||||
|
||||
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<AppProps, AppState> {
|
||||
}) => {
|
||||
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<AppProps, AppState> {
|
||||
this.state,
|
||||
this.scene.getElementsIncludingDeleted(),
|
||||
fileHandle,
|
||||
this.schemaMigrationRegistry,
|
||||
);
|
||||
this.syncActionResult({
|
||||
...scene,
|
||||
@@ -11474,7 +11494,11 @@ class App extends React.Component<AppProps, AppState> {
|
||||
);
|
||||
// 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<AppProps, AppState> {
|
||||
this.state,
|
||||
elements,
|
||||
fileHandle,
|
||||
this.schemaMigrationRegistry,
|
||||
);
|
||||
} catch (error: any) {
|
||||
const imageSceneDataError = error instanceof ImageSceneDataError;
|
||||
|
||||
@@ -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<string> => {
|
||||
@@ -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 (
|
||||
|
||||
@@ -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?: {
|
||||
|
||||
@@ -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<LibraryItems> {
|
||||
const task = () =>
|
||||
new Promise<LibraryItems>(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 <T>(
|
||||
adapter: LibraryPersistenceAdapter,
|
||||
schemaMigrationRegistry: SchemaMigrationRegistry | undefined,
|
||||
fn: (transaction: AdapterTransaction) => Promise<T>,
|
||||
) => {
|
||||
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<LibraryItems> => {
|
||||
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) {
|
||||
|
||||
@@ -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 = <T extends ExcalidrawElement>(
|
||||
refreshDimensions?: boolean;
|
||||
repairBindings?: boolean;
|
||||
deleteInvisibleElements?: boolean;
|
||||
schemaMigrationRegistry?: SchemaMigrationRegistry;
|
||||
}
|
||||
| undefined,
|
||||
): CombineBrandsIfNeeded<T, OrderedExcalidrawElement> => {
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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<Record<string, number>>;
|
||||
}>;
|
||||
|
||||
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<string>();
|
||||
|
||||
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<Record<string, number>> => {
|
||||
const supportedTrackVersions: Record<string, number> = {
|
||||
...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),
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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}
|
||||
</App>
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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<typeof App>["resetCursor"];
|
||||
toggleSidebar: InstanceType<typeof App>["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
|
||||
|
||||
Reference in New Issue
Block a user