feat: wire schema migration plugins end-to-end
This commit is contained in:
@@ -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,
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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 (
|
||||||
|
|||||||
@@ -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?: {
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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),
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user