refactor: migrate elements by schema in restore

This commit is contained in:
Ryan Di
2026-03-23 17:30:05 +11:00
parent 16838ea792
commit 11ba6784aa
5 changed files with 44 additions and 146 deletions
+3
View File
@@ -50,6 +50,8 @@ import type {
ExcalidrawLineElement, ExcalidrawLineElement,
} from "./types"; } from "./types";
const ELEMENT_SCHEMA_VERSION = 2;
export type ElementConstructorOpts = MarkOptional< export type ElementConstructorOpts = MarkOptional<
Omit<ExcalidrawGenericElement, "id" | "type" | "isDeleted" | "updated">, Omit<ExcalidrawGenericElement, "id" | "type" | "isDeleted" | "updated">,
| "width" | "width"
@@ -144,6 +146,7 @@ const _newElementBase = <T extends ExcalidrawElement>(
roundness, roundness,
seed: rest.seed ?? randomInteger(), seed: rest.seed ?? randomInteger(),
version: rest.version || 1, version: rest.version || 1,
schemaVersion: rest.schemaVersion ?? ELEMENT_SCHEMA_VERSION,
versionNonce: rest.versionNonce ?? 0, versionNonce: rest.versionNonce ?? 0,
isDeleted: false as false, isDeleted: false as false,
boundElements, boundElements,
+2
View File
@@ -58,6 +58,8 @@ type _ExcalidrawElementBase = Readonly<{
/** Integer that is sequentially incremented on each change. Used to reconcile /** Integer that is sequentially incremented on each change. Used to reconcile
elements during collaboration or when saving to server. */ elements during collaboration or when saving to server. */
version: number; version: number;
/** Schema version hint used for per-element migration on restore. */
schemaVersion?: number;
/** Random integer that is regenerated on each change. /** Random integer that is regenerated on each change.
Used for deterministic reconciliation of updates during collaboration, Used for deterministic reconciliation of updates during collaboration,
in case the versions (see above) are identical. */ in case the versions (see above) are identical. */
+23 -35
View File
@@ -82,11 +82,7 @@ import {
getNormalizedZoom, getNormalizedZoom,
} from "../scene"; } from "../scene";
import { import { migrateElements, SCHEMA_VERSIONS } from "./schema";
migrateLibraryElements,
type SchemaVersionSource,
SCHEMA_VERSIONS,
} from "./schema";
import type { import type {
AppState, AppState,
@@ -249,7 +245,7 @@ const repairBinding = <T extends ExcalidrawArrowElement>(
}; };
const restoreElementWithProperties = < const restoreElementWithProperties = <
T extends Required<Omit<ExcalidrawElement, "customData">> & { T extends Omit<ExcalidrawElement, "customData"> & {
customData?: ExcalidrawElement["customData"]; customData?: ExcalidrawElement["customData"];
/** @deprecated */ /** @deprecated */
boundElementIds?: readonly ExcalidrawElement["id"][]; boundElementIds?: readonly ExcalidrawElement["id"][];
@@ -291,6 +287,7 @@ const restoreElementWithProperties = <
width: element.width || 0, width: element.width || 0,
height: element.height || 0, height: element.height || 0,
seed: element.seed ?? 1, seed: element.seed ?? 1,
schemaVersion: element.schemaVersion ?? SCHEMA_VERSIONS.latest,
groupIds: element.groupIds ?? [], groupIds: element.groupIds ?? [],
frameId: element.frameId ?? null, frameId: element.frameId ?? null,
roundness: element.roundness roundness: element.roundness
@@ -645,14 +642,18 @@ export const restoreElements = <T extends ExcalidrawElement>(
} }
| undefined, | undefined,
): CombineBrandsIfNeeded<T, OrderedExcalidrawElement> => { ): CombineBrandsIfNeeded<T, OrderedExcalidrawElement> => {
const migratedTargetElements = migrateElements(
targetElements as readonly ExcalidrawElement[] | undefined | null,
) as readonly T[] | undefined | null;
// used to detect duplicate top-level element ids // used to detect duplicate top-level element ids
const existingIds = new Set<string>(); const existingIds = new Set<string>();
const targetElementsMap = arrayToMap(targetElements || []); const targetElementsMap = arrayToMap(migratedTargetElements || []);
const existingElementsMap = existingElements const existingElementsMap = existingElements
? arrayToMap(existingElements) ? arrayToMap(existingElements)
: null; : null;
const restoredElements = syncInvalidIndices( const restoredElements = syncInvalidIndices(
(targetElements || []).reduce((elements, element) => { (migratedTargetElements || []).reduce((elements, element) => {
// filtering out selection, which is legacy, no longer kept in elements, // filtering out selection, which is legacy, no longer kept in elements,
// and causing issues if retained // and causing issues if retained
if (element.type === "selection") { if (element.type === "selection") {
@@ -962,15 +963,9 @@ export const restoreAppState = (
}; };
}; };
const restoreLibraryItem = ( const restoreLibraryItem = (libraryItem: LibraryItem) => {
libraryItem: LibraryItem,
schemaVersionSource: SchemaVersionSource,
) => {
const elements = restoreElements( const elements = restoreElements(
migrateLibraryElements( getNonDeletedElements(libraryItem.elements),
getNonDeletedElements(libraryItem.elements),
schemaVersionSource,
),
null, null,
); );
return elements.length ? { ...libraryItem, elements } : null; return elements.length ? { ...libraryItem, elements } : null;
@@ -979,21 +974,17 @@ const restoreLibraryItem = (
export const restoreLibraryItems = ( export const restoreLibraryItems = (
libraryItems: ImportedDataState["libraryItems"] = [], libraryItems: ImportedDataState["libraryItems"] = [],
defaultStatus: LibraryItem["status"], defaultStatus: LibraryItem["status"],
schemaVersionSource: SchemaVersionSource = SCHEMA_VERSIONS.latest,
) => { ) => {
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,
status: defaultStatus, elements: item,
elements: item, id: randomId(),
id: randomId(), created: Date.now(),
created: Date.now(), });
},
schemaVersionSource,
);
if (restoredItem) { if (restoredItem) {
restoredItems.push(restoredItem); restoredItems.push(restoredItem);
} }
@@ -1002,15 +993,12 @@ export const restoreLibraryItems = (
LibraryItem, LibraryItem,
"id" | "status" | "created" "id" | "status" | "created"
>; >;
const restoredItem = restoreLibraryItem( const restoredItem = restoreLibraryItem({
{ ..._item,
..._item, id: _item.id || randomId(),
id: _item.id || randomId(), status: _item.status || defaultStatus,
status: _item.status || defaultStatus, created: _item.created || Date.now(),
created: _item.created || Date.now(), });
},
schemaVersionSource,
);
if (restoredItem) { if (restoredItem) {
restoredItems.push(restoredItem); restoredItems.push(restoredItem);
} }
+16 -108
View File
@@ -8,36 +8,21 @@ export const SCHEMA_VERSIONS = {
latest: 2, latest: 2,
} as const; } as const;
export const SCHEMA_MIGRATION_SCOPES = [
"scene",
"library",
"clipboard",
"api",
] as const;
export type SchemaMigrationScope = typeof SCHEMA_MIGRATION_SCOPES[number];
export const ALL_SCOPES: readonly SchemaMigrationScope[] =
SCHEMA_MIGRATION_SCOPES;
/** /**
* Schema migration contract: * Schema migration contract:
* - `version`: integer schema version, strictly increasing. * - `version`: integer schema version, strictly increasing.
* - `title`: short human-readable label. * - `title`: short human-readable label.
* - `description`: required plain-language explanation of the change. * - `description`: required plain-language explanation of the change.
* - `scope`: one or more ingestion boundaries (`scene`, `library`, `clipboard`, `api`).
* - `apply`: pure element transform. * - `apply`: pure element transform.
* *
* Rules: * Rules:
* - Use integer versions only. `SCHEMA_VERSIONS.latest` must match the last migration version. * - Use integer versions only. `SCHEMA_VERSIONS.latest` must match the last migration version.
* - Prefer explicit scopes; use `ALL_SCOPES` only when the same transform is required everywhere.
* - Migrations should depend only on stable persisted schema fields (not temporary/PR-only fields). * - Migrations should depend only on stable persisted schema fields (not temporary/PR-only fields).
*/ */
export type SchemaMigration = { export type SchemaMigration = {
version: number; version: number;
title: string; title: string;
description: string; description: string;
scope: readonly SchemaMigrationScope[];
apply: ( apply: (
elements: readonly ExcalidrawElement[], elements: readonly ExcalidrawElement[],
) => readonly ExcalidrawElement[]; ) => readonly ExcalidrawElement[];
@@ -49,7 +34,6 @@ export const SCHEMA_MIGRATIONS: readonly SchemaMigration[] = [
title: "Normalize legacy frame backgrounds", title: "Normalize legacy frame backgrounds",
description: description:
"Frames saved before schema v2 must render without visible fill, so normalize their backgroundColor to transparent on restore.", "Frames saved before schema v2 must render without visible fill, so normalize their backgroundColor to transparent on restore.",
scope: ALL_SCOPES,
apply: (elements) => apply: (elements) =>
elements.map((element) => { elements.map((element) => {
if (element.type !== "frame") { if (element.type !== "frame") {
@@ -63,37 +47,16 @@ export const SCHEMA_MIGRATIONS: readonly SchemaMigration[] = [
}, },
]; ];
export const resolveSchemaVersion = ( export const resolveSchemaVersion = (schemaVersion: number | undefined) => {
schemaVersion: number | undefined,
fallbackVersion: number,
) => {
if ( if (
Number.isInteger(schemaVersion) && Number.isInteger(schemaVersion) &&
(schemaVersion as number) >= SCHEMA_VERSIONS.initial (schemaVersion as number) >= SCHEMA_VERSIONS.initial
) { ) {
return schemaVersion as number; return schemaVersion as number;
} }
return fallbackVersion; return SCHEMA_VERSIONS.initial;
}; };
const isValidSchemaVersion = (
schemaVersion: number | undefined,
): schemaVersion is number => {
return (
Number.isInteger(schemaVersion) &&
(schemaVersion as number) >= SCHEMA_VERSIONS.initial
);
};
export const hasElementSchemaVersion = (
elements: readonly ExcalidrawElement[] | null | undefined,
) =>
!!elements?.some((element) =>
isValidSchemaVersion(
(element as ExcalidrawElement & { schemaVersion?: number }).schemaVersion,
),
);
export const validateSchemaMigrations = ( export const validateSchemaMigrations = (
migrations: readonly SchemaMigration[], migrations: readonly SchemaMigration[],
) => { ) => {
@@ -134,18 +97,6 @@ export const validateSchemaMigrations = (
`Migration "${migration.title}" must include a non-empty description.`, `Migration "${migration.title}" must include a non-empty description.`,
); );
} }
if (!migration.scope.length) {
errors.push(
`Migration "${migration.title}" must declare at least one scope.`,
);
}
for (const scope of migration.scope) {
if (!SCHEMA_MIGRATION_SCOPES.includes(scope)) {
errors.push(
`Migration "${migration.title}" contains unsupported scope "${scope}".`,
);
}
}
} }
if (migrations.length > 0 && previousVersion !== SCHEMA_VERSIONS.latest) { if (migrations.length > 0 && previousVersion !== SCHEMA_VERSIONS.latest) {
@@ -171,7 +122,6 @@ export const migrateElementsBySchema = (
elements: readonly ExcalidrawElement[] | null | undefined, elements: readonly ExcalidrawElement[] | null | undefined,
opts: { opts: {
schemaVersion: number; schemaVersion: number;
scope: SchemaMigrationScope;
}, },
) => { ) => {
if (!elements) { if (!elements) {
@@ -183,74 +133,32 @@ export const migrateElementsBySchema = (
if (migration.version <= opts.schemaVersion) { if (migration.version <= opts.schemaVersion) {
return acc; return acc;
} }
if (!migration.scope.includes(opts.scope)) {
return acc;
}
return migration.apply(acc); return migration.apply(acc);
}, },
elements, elements,
); );
}; };
export type SchemaVersionSource = export const migrateElements = (
| number
| {
payloadSchemaVersion?: number;
fallbackVersion: number;
};
const migrateElementsByScope = (
elements: readonly ExcalidrawElement[] | null | undefined, elements: readonly ExcalidrawElement[] | null | undefined,
scope: SchemaMigrationScope,
source: SchemaVersionSource,
) => { ) => {
if (!elements) { if (!elements) {
return elements; return elements;
} }
if (typeof source === "number") {
return migrateElementsBySchema(elements, {
schemaVersion: source,
scope,
});
}
if (isValidSchemaVersion(source.payloadSchemaVersion)) {
return migrateElementsBySchema(elements, {
schemaVersion: source.payloadSchemaVersion,
scope,
});
}
return elements.map((element) => { return elements.map((element) => {
const schemaVersion = resolveSchemaVersion( const schemaVersion = resolveSchemaVersion(element.schemaVersion);
(element as ExcalidrawElement & { schemaVersion?: number }).schemaVersion, const migratedElement =
source.fallbackVersion, migrateElementsBySchema([element], {
); schemaVersion,
const migratedElement = migrateElementsBySchema([element], { })?.[0] || element;
schemaVersion,
scope, if (
}); resolveSchemaVersion(migratedElement.schemaVersion) <
return migratedElement?.[0] || element; SCHEMA_VERSIONS.latest
) {
(migratedElement as any).schemaVersion = SCHEMA_VERSIONS.latest;
}
return migratedElement;
}); });
}; };
export const migrateSceneElements = (
elements: readonly ExcalidrawElement[] | null | undefined,
source: SchemaVersionSource,
) => migrateElementsByScope(elements, "scene", source);
export const migrateLibraryElements = (
elements: readonly ExcalidrawElement[] | null | undefined,
source: SchemaVersionSource,
) => migrateElementsByScope(elements, "library", source);
export const migrateClipboardElements = (
elements: readonly ExcalidrawElement[] | null | undefined,
source: SchemaVersionSource,
) => migrateElementsByScope(elements, "clipboard", source);
export const migrateAPIElements = (
elements: readonly ExcalidrawElement[] | null | undefined,
source: SchemaVersionSource,
) => migrateElementsByScope(elements, "api", source);
-3
View File
@@ -14,7 +14,6 @@ import type {
export interface ExportedDataState { export interface ExportedDataState {
type: string; type: string;
version: number; version: number;
schemaVersion: number;
source: string; source: string;
elements: readonly ExcalidrawElement[]; elements: readonly ExcalidrawElement[];
appState: ReturnType<typeof cleanAppStateForExport>; appState: ReturnType<typeof cleanAppStateForExport>;
@@ -36,7 +35,6 @@ export type LegacyAppState = {
export interface ImportedDataState { export interface ImportedDataState {
type?: string; type?: string;
version?: number; version?: number;
schemaVersion?: number;
source?: string; source?: string;
elements?: readonly ExcalidrawElement[] | null; elements?: readonly ExcalidrawElement[] | null;
appState?: Readonly< appState?: Readonly<
@@ -54,7 +52,6 @@ export interface ImportedDataState {
export interface ExportedLibraryData { export interface ExportedLibraryData {
type: string; type: string;
version: typeof VERSIONS.excalidrawLibrary; version: typeof VERSIONS.excalidrawLibrary;
schemaVersion: number;
source: string; source: string;
libraryItems: LibraryItems; libraryItems: LibraryItems;
} }