diff options
| author | Maze Winther <mazewinther@gmail.com> | 2026-02-01 12:15:37 +0100 |
|---|---|---|
| committer | Maze Winther <mazewinther@gmail.com> | 2026-02-01 12:15:37 +0100 |
| commit | 2759d7ed31587efa994be20ee3f2df1a90bca0bd (patch) | |
| tree | 1ca72dc05408c507f86ab9e17801a2ea033195a3 /apps/web/src/services/storage/migrations/transformers | |
| parent | 03c2b16064833c85ca8ff74bff7510947728f07b (diff) | |
| download | opencut-2759d7ed31587efa994be20ee3f2df1a90bca0bd.tar.gz | |
add tests for storage migrations
Diffstat (limited to 'apps/web/src/services/storage/migrations/transformers')
5 files changed, 507 insertions, 0 deletions
diff --git a/apps/web/src/services/storage/migrations/transformers/index.ts b/apps/web/src/services/storage/migrations/transformers/index.ts new file mode 100644 index 00000000..74cf46a9 --- /dev/null +++ b/apps/web/src/services/storage/migrations/transformers/index.ts @@ -0,0 +1,4 @@ +export { transformProjectV0ToV1 } from "./v0-to-v1"; +export { transformProjectV1ToV2 } from "./v1-to-v2"; +export { transformProjectV2ToV3 } from "./v2-to-v3"; +export type { MigrationResult, ProjectRecord } from "./types"; diff --git a/apps/web/src/services/storage/migrations/transformers/types.ts b/apps/web/src/services/storage/migrations/transformers/types.ts new file mode 100644 index 00000000..b5ff8a03 --- /dev/null +++ b/apps/web/src/services/storage/migrations/transformers/types.ts @@ -0,0 +1,13 @@ +/** + * Type definitions for different project versions used in migrations. + * These types are intentionally loose (using Record) because we're dealing + * with potentially malformed data from older versions. + */ + +export type ProjectRecord = Record<string, unknown>; + +export interface MigrationResult<T> { + project: T; + skipped: boolean; + reason?: string; +} diff --git a/apps/web/src/services/storage/migrations/transformers/v0-to-v1.ts b/apps/web/src/services/storage/migrations/transformers/v0-to-v1.ts new file mode 100644 index 00000000..d2259b64 --- /dev/null +++ b/apps/web/src/services/storage/migrations/transformers/v0-to-v1.ts @@ -0,0 +1,83 @@ +import { buildDefaultScene } from "@/lib/scenes"; +import type { SerializedScene } from "@/services/storage/types"; +import type { TScene } from "@/types/timeline"; +import type { MigrationResult, ProjectRecord } from "./types"; + +export interface TransformV0ToV1Options { + now?: Date; +} + +export function transformProjectV0ToV1({ + project, + options = {}, +}: { + project: ProjectRecord; + options?: TransformV0ToV1Options; +}): MigrationResult<ProjectRecord> { + const { now = new Date() } = options; + + const scenesValue = project.scenes; + if (Array.isArray(scenesValue) && scenesValue.length > 0) { + return { project, skipped: true, reason: "already has scenes" }; + } + + const mainScene = buildDefaultScene({ isMain: true, name: "Main scene" }); + const serializedScene = serializeScene({ scene: mainScene }); + const updatedProject: ProjectRecord = { + ...project, + scenes: [serializedScene], + currentSceneId: mainScene.id, + version: 1, + }; + + const updatedAt = now.toISOString(); + if (isRecord(project.metadata)) { + updatedProject.metadata = { + ...project.metadata, + updatedAt, + }; + } else { + updatedProject.updatedAt = updatedAt; + } + + return { project: updatedProject, skipped: false }; +} + +export function getProjectId({ + project, +}: { + project: ProjectRecord; +}): string | null { + const idValue = project.id; + if (typeof idValue === "string" && idValue.length > 0) { + return idValue; + } + + const metadataValue = project.metadata; + if (!isRecord(metadataValue)) { + return null; + } + + const metadataId = metadataValue.id; + if (typeof metadataId === "string" && metadataId.length > 0) { + return metadataId; + } + + return null; +} + +function serializeScene({ scene }: { scene: TScene }): SerializedScene { + return { + id: scene.id, + name: scene.name, + isMain: scene.isMain, + tracks: scene.tracks, + bookmarks: scene.bookmarks, + createdAt: scene.createdAt.toISOString(), + updatedAt: scene.updatedAt.toISOString(), + }; +} + +function isRecord(value: unknown): value is ProjectRecord { + return typeof value === "object" && value !== null; +} diff --git a/apps/web/src/services/storage/migrations/transformers/v1-to-v2.ts b/apps/web/src/services/storage/migrations/transformers/v1-to-v2.ts new file mode 100644 index 00000000..a6aaa195 --- /dev/null +++ b/apps/web/src/services/storage/migrations/transformers/v1-to-v2.ts @@ -0,0 +1,326 @@ +import { + DEFAULT_BLUR_INTENSITY, + DEFAULT_CANVAS_SIZE, + DEFAULT_COLOR, + DEFAULT_FPS, +} from "@/constants/project-constants"; +import type { MigrationResult, ProjectRecord } from "./types"; + +export function transformProjectV1ToV2({ + project, +}: { + project: ProjectRecord; +}): MigrationResult<ProjectRecord> { + const projectId = getProjectId({ project }); + if (!projectId) { + return { project, skipped: true, reason: "no project id" }; + } + + if (isV2Project({ project })) { + return { project, skipped: true, reason: "already v2" }; + } + + const migratedProject = migrateProject({ project, projectId }); + return { project: migratedProject, skipped: false }; +} + +function migrateProject({ + project, + projectId, +}: { + project: ProjectRecord; + projectId: string; +}): ProjectRecord { + const createdAt = normalizeDateString({ value: project.createdAt }); + const updatedAt = normalizeDateString({ value: project.updatedAt }); + const metadataValue = project.metadata; + + const metadata = isRecord(metadataValue) + ? { + id: getStringValue({ value: metadataValue.id, fallback: projectId }), + name: getStringValue({ value: metadataValue.name, fallback: "" }), + thumbnail: getStringValue({ value: metadataValue.thumbnail }), + createdAt: normalizeDateString({ value: metadataValue.createdAt }), + updatedAt: normalizeDateString({ value: metadataValue.updatedAt }), + } + : { + id: projectId, + name: getStringValue({ value: project.name, fallback: "" }), + thumbnail: getStringValue({ value: project.thumbnail }), + createdAt, + updatedAt, + }; + + const scenesValue = project.scenes; + const scenes = Array.isArray(scenesValue) ? scenesValue : []; + const legacyBookmarks = Array.isArray(project.bookmarks) + ? project.bookmarks + : null; + const normalizedScenes = applyLegacyBookmarks({ + scenes, + legacyBookmarks, + }); + + const settingsValue = project.settings; + const settings = isRecord(settingsValue) + ? { + fps: getNumberValue({ + value: settingsValue.fps, + fallback: DEFAULT_FPS, + }), + canvasSize: getCanvasSizeValue({ + value: settingsValue.canvasSize, + fallback: DEFAULT_CANVAS_SIZE, + }), + background: getBackgroundValue({ + value: settingsValue.background, + }), + } + : { + fps: getNumberValue({ value: project.fps, fallback: DEFAULT_FPS }), + canvasSize: getCanvasSizeValue({ + value: project.canvasSize, + fallback: DEFAULT_CANVAS_SIZE, + }), + background: getBackgroundValue({ + value: project.background, + backgroundType: project.backgroundType, + backgroundColor: project.backgroundColor, + blurIntensity: project.blurIntensity, + }), + }; + + const currentSceneId = getCurrentSceneId({ + value: project.currentSceneId, + scenes: normalizedScenes, + }); + + return { + ...project, + metadata, + scenes: normalizedScenes, + currentSceneId, + settings, + version: 2, + }; +} + +export function getProjectId({ + project, +}: { + project: ProjectRecord; +}): string | null { + const idValue = project.id; + if (typeof idValue === "string" && idValue.length > 0) { + return idValue; + } + + const metadataValue = project.metadata; + if (!isRecord(metadataValue)) { + return null; + } + + const metadataId = metadataValue.id; + if (typeof metadataId === "string" && metadataId.length > 0) { + return metadataId; + } + + return null; +} + +function getCurrentSceneId({ + value, + scenes, +}: { + value: unknown; + scenes: unknown[]; +}): string { + if (typeof value === "string" && value.length > 0) { + return value; + } + + const mainSceneId = findMainSceneId({ scenes }); + if (mainSceneId) { + return mainSceneId; + } + + return ""; +} + +function findMainSceneId({ scenes }: { scenes: unknown[] }): string | null { + for (const scene of scenes) { + if (!isRecord(scene)) { + continue; + } + + if (scene.isMain === true && typeof scene.id === "string") { + return scene.id; + } + } + + for (const scene of scenes) { + if (!isRecord(scene)) { + continue; + } + + if (typeof scene.id === "string") { + return scene.id; + } + } + + return null; +} + +function applyLegacyBookmarks({ + scenes, + legacyBookmarks, +}: { + scenes: unknown[]; + legacyBookmarks: unknown[] | null; +}): unknown[] { + if (!legacyBookmarks || legacyBookmarks.length === 0) { + return scenes; + } + + const mainSceneId = findMainSceneId({ scenes }); + + return scenes.map((scene) => { + if (!isRecord(scene)) { + return scene; + } + + if (mainSceneId && scene.id !== mainSceneId) { + return scene; + } + + if (Array.isArray(scene.bookmarks) && scene.bookmarks.length > 0) { + return scene; + } + + return { + ...scene, + bookmarks: legacyBookmarks, + }; + }); +} + +function getBackgroundValue({ + value, + backgroundType, + backgroundColor, + blurIntensity, +}: { + value?: unknown; + backgroundType?: unknown; + backgroundColor?: unknown; + blurIntensity?: unknown; +}): { + type: "color" | "blur"; + color?: string; + blurIntensity?: number; +} { + if (isRecord(value)) { + const typeValue = value.type; + if (typeValue === "blur") { + return { + type: "blur", + blurIntensity: getNumberValue({ + value: value.blurIntensity, + fallback: DEFAULT_BLUR_INTENSITY, + }), + }; + } + + return { + type: "color", + color: getStringValue({ value: value.color, fallback: DEFAULT_COLOR }), + }; + } + + if (backgroundType === "blur") { + return { + type: "blur", + blurIntensity: getNumberValue({ + value: blurIntensity, + fallback: DEFAULT_BLUR_INTENSITY, + }), + }; + } + + return { + type: "color", + color: getStringValue({ value: backgroundColor, fallback: DEFAULT_COLOR }), + }; +} + +function getCanvasSizeValue({ + value, + fallback, +}: { + value: unknown; + fallback: { width: number; height: number }; +}): { width: number; height: number } { + if (isRecord(value)) { + const width = getNumberValue({ + value: value.width, + fallback: fallback.width, + }); + const height = getNumberValue({ + value: value.height, + fallback: fallback.height, + }); + + return { width, height }; + } + + return fallback; +} + +function getNumberValue({ + value, + fallback, +}: { + value: unknown; + fallback: number; +}): number { + return typeof value === "number" ? value : fallback; +} + +function getStringValue({ + value, + fallback, +}: { + value: unknown; + fallback?: string; +}): string | undefined { + if (typeof value === "string") { + return value; + } + + return fallback; +} + +function normalizeDateString({ value }: { value: unknown }): string { + if (value instanceof Date) { + return value.toISOString(); + } + + if (typeof value === "string") { + return value; + } + + return new Date().toISOString(); +} + +function isV2Project({ project }: { project: ProjectRecord }): boolean { + const versionValue = project.version; + if (typeof versionValue === "number" && versionValue >= 2) { + return true; + } + + return isRecord(project.metadata) && isRecord(project.settings); +} + +function isRecord(value: unknown): value is ProjectRecord { + return typeof value === "object" && value !== null; +} diff --git a/apps/web/src/services/storage/migrations/transformers/v2-to-v3.ts b/apps/web/src/services/storage/migrations/transformers/v2-to-v3.ts new file mode 100644 index 00000000..33656109 --- /dev/null +++ b/apps/web/src/services/storage/migrations/transformers/v2-to-v3.ts @@ -0,0 +1,81 @@ +import { getProjectDurationFromScenes } from "@/lib/scenes"; +import type { TScene } from "@/types/timeline"; +import type { MigrationResult, ProjectRecord } from "./types"; + +export function transformProjectV2ToV3({ + project, +}: { + project: ProjectRecord; +}): MigrationResult<ProjectRecord> { + const projectId = getProjectId({ project }); + if (!projectId) { + return { project, skipped: true, reason: "no project id" }; + } + + if (isV3Project({ project })) { + return { project, skipped: true, reason: "already v3" }; + } + + const scenes = getScenes({ project }); + const duration = getProjectDurationFromScenes({ scenes }); + + const metadataValue = project.metadata; + const metadata = isRecord(metadataValue) + ? { ...metadataValue, duration } + : { duration }; + + const migratedProject = { + ...project, + metadata, + version: 3, + }; + + return { project: migratedProject, skipped: false }; +} + +export function getProjectId({ + project, +}: { + project: ProjectRecord; +}): string | null { + const idValue = project.id; + if (typeof idValue === "string" && idValue.length > 0) { + return idValue; + } + + const metadataValue = project.metadata; + if (!isRecord(metadataValue)) { + return null; + } + + const metadataId = metadataValue.id; + if (typeof metadataId === "string" && metadataId.length > 0) { + return metadataId; + } + + return null; +} + +function getScenes({ project }: { project: ProjectRecord }): TScene[] { + const scenesValue = project.scenes; + if (!Array.isArray(scenesValue)) { + return []; + } + + return scenesValue.filter(isRecord) as unknown as TScene[]; +} + +function isV3Project({ project }: { project: ProjectRecord }): boolean { + const versionValue = project.version; + if (typeof versionValue === "number" && versionValue >= 3) { + return true; + } + + return ( + isRecord(project.metadata) && typeof project.metadata.duration === "number" + ); +} + +function isRecord(value: unknown): value is ProjectRecord { + return typeof value === "object" && value !== null; +} |
