refactor: migrate elements by schema in restore
This commit is contained in:
@@ -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,
|
||||||
|
|||||||
@@ -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. */
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user