refactor: migrate elements by schema in restore
This commit is contained in:
@@ -50,6 +50,8 @@ import type {
|
||||
ExcalidrawLineElement,
|
||||
} from "./types";
|
||||
|
||||
const ELEMENT_SCHEMA_VERSION = 2;
|
||||
|
||||
export type ElementConstructorOpts = MarkOptional<
|
||||
Omit<ExcalidrawGenericElement, "id" | "type" | "isDeleted" | "updated">,
|
||||
| "width"
|
||||
@@ -144,6 +146,7 @@ const _newElementBase = <T extends ExcalidrawElement>(
|
||||
roundness,
|
||||
seed: rest.seed ?? randomInteger(),
|
||||
version: rest.version || 1,
|
||||
schemaVersion: rest.schemaVersion ?? ELEMENT_SCHEMA_VERSION,
|
||||
versionNonce: rest.versionNonce ?? 0,
|
||||
isDeleted: false as false,
|
||||
boundElements,
|
||||
|
||||
@@ -58,6 +58,8 @@ type _ExcalidrawElementBase = Readonly<{
|
||||
/** Integer that is sequentially incremented on each change. Used to reconcile
|
||||
elements during collaboration or when saving to server. */
|
||||
version: number;
|
||||
/** Schema version hint used for per-element migration on restore. */
|
||||
schemaVersion?: number;
|
||||
/** Random integer that is regenerated on each change.
|
||||
Used for deterministic reconciliation of updates during collaboration,
|
||||
in case the versions (see above) are identical. */
|
||||
|
||||
@@ -82,11 +82,7 @@ import {
|
||||
getNormalizedZoom,
|
||||
} from "../scene";
|
||||
|
||||
import {
|
||||
migrateLibraryElements,
|
||||
type SchemaVersionSource,
|
||||
SCHEMA_VERSIONS,
|
||||
} from "./schema";
|
||||
import { migrateElements, SCHEMA_VERSIONS } from "./schema";
|
||||
|
||||
import type {
|
||||
AppState,
|
||||
@@ -249,7 +245,7 @@ const repairBinding = <T extends ExcalidrawArrowElement>(
|
||||
};
|
||||
|
||||
const restoreElementWithProperties = <
|
||||
T extends Required<Omit<ExcalidrawElement, "customData">> & {
|
||||
T extends Omit<ExcalidrawElement, "customData"> & {
|
||||
customData?: ExcalidrawElement["customData"];
|
||||
/** @deprecated */
|
||||
boundElementIds?: readonly ExcalidrawElement["id"][];
|
||||
@@ -291,6 +287,7 @@ const restoreElementWithProperties = <
|
||||
width: element.width || 0,
|
||||
height: element.height || 0,
|
||||
seed: element.seed ?? 1,
|
||||
schemaVersion: element.schemaVersion ?? SCHEMA_VERSIONS.latest,
|
||||
groupIds: element.groupIds ?? [],
|
||||
frameId: element.frameId ?? null,
|
||||
roundness: element.roundness
|
||||
@@ -645,14 +642,18 @@ export const restoreElements = <T extends ExcalidrawElement>(
|
||||
}
|
||||
| undefined,
|
||||
): 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
|
||||
const existingIds = new Set<string>();
|
||||
const targetElementsMap = arrayToMap(targetElements || []);
|
||||
const targetElementsMap = arrayToMap(migratedTargetElements || []);
|
||||
const existingElementsMap = existingElements
|
||||
? arrayToMap(existingElements)
|
||||
: null;
|
||||
const restoredElements = syncInvalidIndices(
|
||||
(targetElements || []).reduce((elements, element) => {
|
||||
(migratedTargetElements || []).reduce((elements, element) => {
|
||||
// filtering out selection, which is legacy, no longer kept in elements,
|
||||
// and causing issues if retained
|
||||
if (element.type === "selection") {
|
||||
@@ -962,15 +963,9 @@ export const restoreAppState = (
|
||||
};
|
||||
};
|
||||
|
||||
const restoreLibraryItem = (
|
||||
libraryItem: LibraryItem,
|
||||
schemaVersionSource: SchemaVersionSource,
|
||||
) => {
|
||||
const restoreLibraryItem = (libraryItem: LibraryItem) => {
|
||||
const elements = restoreElements(
|
||||
migrateLibraryElements(
|
||||
getNonDeletedElements(libraryItem.elements),
|
||||
schemaVersionSource,
|
||||
),
|
||||
getNonDeletedElements(libraryItem.elements),
|
||||
null,
|
||||
);
|
||||
return elements.length ? { ...libraryItem, elements } : null;
|
||||
@@ -979,21 +974,17 @@ const restoreLibraryItem = (
|
||||
export const restoreLibraryItems = (
|
||||
libraryItems: ImportedDataState["libraryItems"] = [],
|
||||
defaultStatus: LibraryItem["status"],
|
||||
schemaVersionSource: SchemaVersionSource = SCHEMA_VERSIONS.latest,
|
||||
) => {
|
||||
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(),
|
||||
},
|
||||
schemaVersionSource,
|
||||
);
|
||||
const restoredItem = restoreLibraryItem({
|
||||
status: defaultStatus,
|
||||
elements: item,
|
||||
id: randomId(),
|
||||
created: Date.now(),
|
||||
});
|
||||
if (restoredItem) {
|
||||
restoredItems.push(restoredItem);
|
||||
}
|
||||
@@ -1002,15 +993,12 @@ export const restoreLibraryItems = (
|
||||
LibraryItem,
|
||||
"id" | "status" | "created"
|
||||
>;
|
||||
const restoredItem = restoreLibraryItem(
|
||||
{
|
||||
..._item,
|
||||
id: _item.id || randomId(),
|
||||
status: _item.status || defaultStatus,
|
||||
created: _item.created || Date.now(),
|
||||
},
|
||||
schemaVersionSource,
|
||||
);
|
||||
const restoredItem = restoreLibraryItem({
|
||||
..._item,
|
||||
id: _item.id || randomId(),
|
||||
status: _item.status || defaultStatus,
|
||||
created: _item.created || Date.now(),
|
||||
});
|
||||
if (restoredItem) {
|
||||
restoredItems.push(restoredItem);
|
||||
}
|
||||
|
||||
@@ -8,36 +8,21 @@ export const SCHEMA_VERSIONS = {
|
||||
latest: 2,
|
||||
} 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:
|
||||
* - `version`: integer schema version, strictly increasing.
|
||||
* - `title`: short human-readable label.
|
||||
* - `description`: required plain-language explanation of the change.
|
||||
* - `scope`: one or more ingestion boundaries (`scene`, `library`, `clipboard`, `api`).
|
||||
* - `apply`: pure element transform.
|
||||
*
|
||||
* Rules:
|
||||
* - 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).
|
||||
*/
|
||||
export type SchemaMigration = {
|
||||
version: number;
|
||||
title: string;
|
||||
description: string;
|
||||
scope: readonly SchemaMigrationScope[];
|
||||
apply: (
|
||||
elements: readonly ExcalidrawElement[],
|
||||
) => readonly ExcalidrawElement[];
|
||||
@@ -49,7 +34,6 @@ export const SCHEMA_MIGRATIONS: readonly SchemaMigration[] = [
|
||||
title: "Normalize legacy frame backgrounds",
|
||||
description:
|
||||
"Frames saved before schema v2 must render without visible fill, so normalize their backgroundColor to transparent on restore.",
|
||||
scope: ALL_SCOPES,
|
||||
apply: (elements) =>
|
||||
elements.map((element) => {
|
||||
if (element.type !== "frame") {
|
||||
@@ -63,37 +47,16 @@ export const SCHEMA_MIGRATIONS: readonly SchemaMigration[] = [
|
||||
},
|
||||
];
|
||||
|
||||
export const resolveSchemaVersion = (
|
||||
schemaVersion: number | undefined,
|
||||
fallbackVersion: number,
|
||||
) => {
|
||||
export const resolveSchemaVersion = (schemaVersion: number | undefined) => {
|
||||
if (
|
||||
Number.isInteger(schemaVersion) &&
|
||||
(schemaVersion as number) >= SCHEMA_VERSIONS.initial
|
||||
) {
|
||||
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 = (
|
||||
migrations: readonly SchemaMigration[],
|
||||
) => {
|
||||
@@ -134,18 +97,6 @@ export const validateSchemaMigrations = (
|
||||
`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) {
|
||||
@@ -171,7 +122,6 @@ export const migrateElementsBySchema = (
|
||||
elements: readonly ExcalidrawElement[] | null | undefined,
|
||||
opts: {
|
||||
schemaVersion: number;
|
||||
scope: SchemaMigrationScope;
|
||||
},
|
||||
) => {
|
||||
if (!elements) {
|
||||
@@ -183,74 +133,32 @@ export const migrateElementsBySchema = (
|
||||
if (migration.version <= opts.schemaVersion) {
|
||||
return acc;
|
||||
}
|
||||
if (!migration.scope.includes(opts.scope)) {
|
||||
return acc;
|
||||
}
|
||||
return migration.apply(acc);
|
||||
},
|
||||
elements,
|
||||
);
|
||||
};
|
||||
|
||||
export type SchemaVersionSource =
|
||||
| number
|
||||
| {
|
||||
payloadSchemaVersion?: number;
|
||||
fallbackVersion: number;
|
||||
};
|
||||
|
||||
const migrateElementsByScope = (
|
||||
export const migrateElements = (
|
||||
elements: readonly ExcalidrawElement[] | null | undefined,
|
||||
scope: SchemaMigrationScope,
|
||||
source: SchemaVersionSource,
|
||||
) => {
|
||||
if (!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) => {
|
||||
const schemaVersion = resolveSchemaVersion(
|
||||
(element as ExcalidrawElement & { schemaVersion?: number }).schemaVersion,
|
||||
source.fallbackVersion,
|
||||
);
|
||||
const migratedElement = migrateElementsBySchema([element], {
|
||||
schemaVersion,
|
||||
scope,
|
||||
});
|
||||
return migratedElement?.[0] || element;
|
||||
const schemaVersion = resolveSchemaVersion(element.schemaVersion);
|
||||
const migratedElement =
|
||||
migrateElementsBySchema([element], {
|
||||
schemaVersion,
|
||||
})?.[0] || element;
|
||||
|
||||
if (
|
||||
resolveSchemaVersion(migratedElement.schemaVersion) <
|
||||
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);
|
||||
|
||||
@@ -14,7 +14,6 @@ import type {
|
||||
export interface ExportedDataState {
|
||||
type: string;
|
||||
version: number;
|
||||
schemaVersion: number;
|
||||
source: string;
|
||||
elements: readonly ExcalidrawElement[];
|
||||
appState: ReturnType<typeof cleanAppStateForExport>;
|
||||
@@ -36,7 +35,6 @@ export type LegacyAppState = {
|
||||
export interface ImportedDataState {
|
||||
type?: string;
|
||||
version?: number;
|
||||
schemaVersion?: number;
|
||||
source?: string;
|
||||
elements?: readonly ExcalidrawElement[] | null;
|
||||
appState?: Readonly<
|
||||
@@ -54,7 +52,6 @@ export interface ImportedDataState {
|
||||
export interface ExportedLibraryData {
|
||||
type: string;
|
||||
version: typeof VERSIONS.excalidrawLibrary;
|
||||
schemaVersion: number;
|
||||
source: string;
|
||||
libraryItems: LibraryItems;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user