feat: wire schema migration plugins end-to-end

This commit is contained in:
Ryan Di
2026-03-25 19:15:56 +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,
appState: loadedAppState,
files,
} = await loadFromJSON(appState, elements);
} = await loadFromJSON(
appState,
elements,
app.getSchemaMigrationRegistry(),
);
return {
elements: loadedElements,
appState: loadedAppState,
+26 -1
View File
@@ -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;
+15 -2
View File
@@ -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 (
+9 -1
View File
@@ -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?: {
+114 -59
View File
@@ -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) {
+30 -13
View File
@@ -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);
}
+78
View File
@@ -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);
});
});
+135 -22
View File
@@ -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),
);
};
+2
View File
@@ -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",
+8
View File
@@ -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