Files
excalidraw/packages/excalidraw/data/schema.ts
T
2026-03-25 20:10:43 +11:00

444 lines
13 KiB
TypeScript

import {
CORE_FRAME_SCHEMA_TRACK,
SCHEMA_CORE_NAMESPACE,
SCHEMA_INITIAL_TRACK_VERSION,
CORE_SUPPORTED_TRACKS,
} from "@excalidraw/element/schema";
import type { SchemaNamespace, SchemaTrack } from "@excalidraw/element/schema";
import type { ExcalidrawElement } from "@excalidraw/element/types";
export {
CORE_FRAME_SCHEMA_TRACK,
CORE_SUPPORTED_TRACKS,
SCHEMA_CORE_NAMESPACE,
SCHEMA_INITIAL_TRACK_VERSION,
};
export type { SchemaNamespace, SchemaTrack };
/**
* 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 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 = {
/** Stable unique id for validation and debugging. */
id: string;
/** Owner of the migration: core or a host namespace. */
namespace: SchemaNamespace;
/** Version line this migration belongs to. */
track: SchemaTrack;
/** Target version reached after applying this migration. */
toVersion: number;
/** Human-readable metadata for maintainers/reviewers. */
title: string;
description: string;
/** Which element types this migration may transform ("*" = all). */
targetTypes: readonly ExcalidrawElement["type"][] | "*";
/** Pure transform for a single element. */
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",
namespace: SCHEMA_CORE_NAMESPACE,
track: CORE_FRAME_SCHEMA_TRACK,
toVersion: CORE_SUPPORTED_TRACKS[CORE_FRAME_SCHEMA_TRACK],
title: "Normalize legacy frame backgrounds",
description:
"Frames saved before frame track v2 must render without visible fill, so normalize backgroundColor to transparent on restore.",
targetTypes: ["frame"],
apply: (element) => {
if (element.type !== "frame") {
return element;
}
return {
...element,
backgroundColor: "transparent",
};
},
},
];
export const resolveTrackVersion = (trackVersion: unknown) => {
if (
Number.isInteger(trackVersion) &&
(trackVersion as number) >= SCHEMA_INITIAL_TRACK_VERSION
) {
return trackVersion as number;
}
return SCHEMA_INITIAL_TRACK_VERSION;
};
const normalizeSchemaTracks = (tracks: unknown) => {
if (!tracks || typeof tracks !== "object") {
return {} as Record<string, number>;
}
return Object.entries(tracks as Record<string, unknown>).reduce<
Record<string, number>
>((acc, [track, version]) => {
const normalizedVersion = resolveTrackVersion(version);
if (normalizedVersion >= SCHEMA_INITIAL_TRACK_VERSION) {
acc[track] = normalizedVersion;
}
return acc;
}, {});
};
const normalizeElementSchemaState = (
element: ExcalidrawElement,
): ExcalidrawElement["schemaState"] => {
const tracks = normalizeSchemaTracks(
(
element as ExcalidrawElement & {
schemaState?: ExcalidrawElement["schemaState"];
}
).schemaState?.tracks,
);
return {
tracks,
};
};
const ensureElementSchemaState = (element: ExcalidrawElement) => {
const normalizedSchemaState = normalizeElementSchemaState(element);
// Fast path: avoid reallocating when element already has normalized state.
if (element.schemaState === normalizedSchemaState) {
return element;
}
if (
element.schemaState &&
Object.keys(element.schemaState?.tracks || {}).length ===
Object.keys(normalizedSchemaState.tracks).length &&
Object.entries(normalizedSchemaState.tracks).every(
([track, version]) => element.schemaState?.tracks?.[track] === version,
)
) {
return element;
}
return {
...element,
schemaState: normalizedSchemaState,
};
};
const getTrackVersion = (element: ExcalidrawElement, track: SchemaTrack) => {
return resolveTrackVersion(element.schemaState.tracks[track]);
};
const withTrackVersion = (
element: ExcalidrawElement,
track: SchemaTrack,
version: number,
) => {
if (element.schemaState.tracks[track] === version) {
return element;
}
return {
...element,
schemaState: {
...element.schemaState,
tracks: {
...element.schemaState.tracks,
[track]: version,
},
},
};
};
const migrationMatchesElementType = (
migration: SchemaMigration,
element: ExcalidrawElement,
) => {
return (
migration.targetTypes === "*" ||
migration.targetTypes.includes(element.type)
);
};
export const validateSchemaMigrations = (
migrations: readonly SchemaMigration[],
) => {
const errors: string[] = [];
const seenIds = new Set<string>();
const previousVersionByTrack = new Map<string, number>();
for (const migration of migrations) {
if (!migration.id.trim()) {
errors.push("Migration id must be non-empty.");
}
if (seenIds.has(migration.id)) {
errors.push(`Duplicate schema migration id found: ${migration.id}.`);
}
seenIds.add(migration.id);
if (!migration.title.trim()) {
errors.push(`Migration "${migration.id}" title must be non-empty.`);
}
if (!migration.description.trim()) {
errors.push(
`Migration "${migration.id}" must include a non-empty description.`,
);
}
if (!Number.isInteger(migration.toVersion)) {
errors.push(`Migration "${migration.id}" must use an integer version.`);
}
if (migration.toVersion <= SCHEMA_INITIAL_TRACK_VERSION) {
errors.push(
`Migration "${migration.id}" version must be greater than ${SCHEMA_INITIAL_TRACK_VERSION}.`,
);
}
if (
migration.targetTypes !== "*" &&
(!migration.targetTypes.length ||
migration.targetTypes.some((type) => !type))
) {
errors.push(
`Migration "${migration.id}" must declare at least one target type.`,
);
}
const trackKey = `${migration.namespace}|${migration.track}`;
const previousVersion =
previousVersionByTrack.get(trackKey) ?? SCHEMA_INITIAL_TRACK_VERSION;
if (migration.toVersion <= previousVersion) {
errors.push(
`Migration "${migration.id}" must be ordered by increasing version within ${trackKey}.`,
);
}
previousVersionByTrack.set(trackKey, migration.toVersion);
if (
migration.namespace === SCHEMA_CORE_NAMESPACE &&
!migration.track.startsWith("excalidraw.")
) {
errors.push(
`Core migration "${migration.id}" must use an excalidraw.* track.`,
);
}
if (
migration.namespace === SCHEMA_CORE_NAMESPACE &&
!(migration.track in CORE_SUPPORTED_TRACKS)
) {
errors.push(
`Core migration "${migration.id}" track "${migration.track}" must be declared in CORE_SUPPORTED_TRACKS.`,
);
}
if (
migration.namespace !== SCHEMA_CORE_NAMESPACE &&
!migration.track.startsWith(`${migration.namespace}.`)
) {
errors.push(
`Host migration "${migration.id}" track must use namespace prefix ${migration.namespace}.`,
);
}
}
for (const [track, supportedVersion] of Object.entries(
CORE_SUPPORTED_TRACKS,
)) {
const migrationTrackKey = `${SCHEMA_CORE_NAMESPACE}|${track}`;
const lastDeclaredVersion =
previousVersionByTrack.get(migrationTrackKey) ??
SCHEMA_INITIAL_TRACK_VERSION;
if (lastDeclaredVersion !== supportedVersion) {
errors.push(
`Core supported track "${track}" (${supportedVersion}) must match last migration version (${lastDeclaredVersion}).`,
);
}
}
return errors;
};
export const validateSchemaPlugins = (plugins: readonly SchemaPlugin[]) => {
const errors: string[] = [];
const seenIds = new Set<string>();
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 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;
}
const currentTrackVersion = getTrackVersion(
migratedElement,
migration.track,
);
const supportedTrackVersion =
schemaMigrationRegistry.supportedTrackVersions[migration.track] ??
currentTrackVersion;
// Never re-run or downgrade.
if (currentTrackVersion >= migration.toVersion) {
continue;
}
// Preserve future data: ignore migrations newer than what this app supports.
if (migration.toVersion > supportedTrackVersion) {
continue;
}
// Apply transform, then stamp the element's track version.
migratedElement = withTrackVersion(
migration.apply(migratedElement),
migration.track,
migration.toVersion,
);
}
return migratedElement;
};
export const migrateElements = (
elements: readonly ExcalidrawElement[] | null | undefined,
opts?: {
schemaMigrationRegistry?: SchemaMigrationRegistry;
},
) => {
if (!elements) {
return elements;
}
const schemaMigrationRegistry = resolveSchemaMigrationRegistry(
opts?.schemaMigrationRegistry,
);
return elements.map((element) =>
migrateElement(element, schemaMigrationRegistry),
);
};