From 2759d7ed31587efa994be20ee3f2df1a90bca0bd Mon Sep 17 00:00:00 2001 From: Maze Winther Date: Sun, 1 Feb 2026 12:15:37 +0100 Subject: [PATCH] add tests for storage migrations --- .../migrations/__tests__/fixtures/index.ts | 9 + .../migrations/__tests__/fixtures/v0.ts | 71 ++++ .../migrations/__tests__/fixtures/v1.ts | 124 +++++++ .../migrations/__tests__/fixtures/v2.ts | 121 +++++++ .../migrations/__tests__/fixtures/v3.ts | 47 +++ .../migrations/__tests__/v0-to-v1.test.ts | 111 ++++++ .../migrations/__tests__/v1-to-v2.test.ts | 165 +++++++++ .../migrations/__tests__/v2-to-v3.test.ts | 175 ++++++++++ .../storage/migrations/transformers/index.ts | 4 + .../storage/migrations/transformers/types.ts | 13 + .../migrations/transformers/v0-to-v1.ts | 83 +++++ .../migrations/transformers/v1-to-v2.ts | 326 ++++++++++++++++++ .../migrations/transformers/v2-to-v3.ts | 81 +++++ .../services/storage/migrations/v0-to-v1.ts | 73 +--- .../services/storage/migrations/v1-to-v2.ts | 325 +---------------- .../services/storage/migrations/v2-to-v3.ts | 78 +---- package.json | 3 +- 17 files changed, 1365 insertions(+), 444 deletions(-) create mode 100644 apps/web/src/services/storage/migrations/__tests__/fixtures/index.ts create mode 100644 apps/web/src/services/storage/migrations/__tests__/fixtures/v0.ts create mode 100644 apps/web/src/services/storage/migrations/__tests__/fixtures/v1.ts create mode 100644 apps/web/src/services/storage/migrations/__tests__/fixtures/v2.ts create mode 100644 apps/web/src/services/storage/migrations/__tests__/fixtures/v3.ts create mode 100644 apps/web/src/services/storage/migrations/__tests__/v0-to-v1.test.ts create mode 100644 apps/web/src/services/storage/migrations/__tests__/v1-to-v2.test.ts create mode 100644 apps/web/src/services/storage/migrations/__tests__/v2-to-v3.test.ts create mode 100644 apps/web/src/services/storage/migrations/transformers/index.ts create mode 100644 apps/web/src/services/storage/migrations/transformers/types.ts create mode 100644 apps/web/src/services/storage/migrations/transformers/v0-to-v1.ts create mode 100644 apps/web/src/services/storage/migrations/transformers/v1-to-v2.ts create mode 100644 apps/web/src/services/storage/migrations/transformers/v2-to-v3.ts diff --git a/apps/web/src/services/storage/migrations/__tests__/fixtures/index.ts b/apps/web/src/services/storage/migrations/__tests__/fixtures/index.ts new file mode 100644 index 00000000..ee1485ba --- /dev/null +++ b/apps/web/src/services/storage/migrations/__tests__/fixtures/index.ts @@ -0,0 +1,9 @@ +/** + * Test fixtures for storage migrations. + * These represent real project data shapes from each version. + */ + +export * from "./v0"; +export * from "./v1"; +export * from "./v2"; +export * from "./v3"; diff --git a/apps/web/src/services/storage/migrations/__tests__/fixtures/v0.ts b/apps/web/src/services/storage/migrations/__tests__/fixtures/v0.ts new file mode 100644 index 00000000..24428cfe --- /dev/null +++ b/apps/web/src/services/storage/migrations/__tests__/fixtures/v0.ts @@ -0,0 +1,71 @@ +export const v0Project = { + id: "project-v0-123", + name: "My V0 Project", + createdAt: "2024-01-15T10:00:00.000Z", + updatedAt: "2024-01-15T12:00:00.000Z", + fps: 30, + canvasSize: { width: 1920, height: 1080 }, + backgroundColor: "#000000", + backgroundType: "color", + tracks: [ + { + id: "track-1", + type: "video", + name: "Video Track", + elements: [ + { + id: "element-1", + type: "video", + mediaId: "media-1", + startTime: 0, + duration: 5, + trimStart: 0, + trimEnd: 0, + }, + ], + }, + ], + bookmarks: [1.5, 3.0], +}; + +export const v0ProjectWithMetadata = { + id: "project-v0-456", + metadata: { + id: "project-v0-456", + name: "V0 With Metadata", + createdAt: "2024-02-01T08:00:00.000Z", + updatedAt: "2024-02-01T09:00:00.000Z", + }, + fps: 24, + canvasSize: { width: 1280, height: 720 }, + backgroundType: "blur", + blurIntensity: 20, +}; + +export const v0ProjectEmpty = { + id: "project-empty", + name: "Empty Project", + createdAt: "2024-03-01T00:00:00.000Z", + updatedAt: "2024-03-01T00:00:00.000Z", +}; + +// Edge cases +export const projectWithNoId = { + name: "No ID Project", + version: 1, + scenes: [], +}; + +export const projectWithNullValues = { + id: "project-nulls", + version: 1, + name: null, + metadata: null, + scenes: null, + settings: null, +}; + +export const projectMalformed = { + id: "project-malformed", + // Missing almost everything +}; diff --git a/apps/web/src/services/storage/migrations/__tests__/fixtures/v1.ts b/apps/web/src/services/storage/migrations/__tests__/fixtures/v1.ts new file mode 100644 index 00000000..93904947 --- /dev/null +++ b/apps/web/src/services/storage/migrations/__tests__/fixtures/v1.ts @@ -0,0 +1,124 @@ +export const v1Project = { + id: "project-v1-123", + version: 1, + name: "My V1 Project", + createdAt: "2024-01-15T10:00:00.000Z", + updatedAt: "2024-01-15T12:00:00.000Z", + fps: 30, + canvasSize: { width: 1920, height: 1080 }, + backgroundColor: "#1a1a1a", + backgroundType: "color", + currentSceneId: "scene-main", + bookmarks: [2.0, 4.5, 7.0], + scenes: [ + { + id: "scene-main", + name: "Main scene", + isMain: true, + tracks: [ + { + id: "track-1", + type: "video", + name: "Video Track", + isMain: true, + elements: [ + { + id: "element-1", + type: "video", + mediaId: "media-1", + startTime: 0, + duration: 10, + trimStart: 0, + trimEnd: 0, + }, + ], + }, + { + id: "track-2", + type: "audio", + name: "Audio Track", + elements: [ + { + id: "element-2", + type: "audio", + sourceType: "upload", + mediaId: "media-2", + startTime: 0, + duration: 10, + trimStart: 0, + trimEnd: 0, + }, + ], + }, + ], + bookmarks: [], + createdAt: "2024-01-15T10:00:00.000Z", + updatedAt: "2024-01-15T12:00:00.000Z", + }, + ], +}; + +export const v1ProjectWithMultipleScenes = { + id: "project-v1-multi", + version: 1, + metadata: { + id: "project-v1-multi", + name: "Multi-Scene Project", + createdAt: "2024-02-20T14:00:00.000Z", + updatedAt: "2024-02-20T16:00:00.000Z", + }, + currentSceneId: "scene-1", + fps: 60, + canvasSize: { width: 3840, height: 2160 }, + background: { type: "blur", blurIntensity: 15 }, + scenes: [ + { + id: "scene-1", + name: "Intro", + isMain: true, + tracks: [ + { + id: "track-1", + type: "video", + isMain: true, + elements: [ + { + id: "el-1", + type: "video", + mediaId: "m1", + startTime: 0, + duration: 5, + }, + ], + }, + ], + bookmarks: [1.0], + createdAt: "2024-02-20T14:00:00.000Z", + updatedAt: "2024-02-20T16:00:00.000Z", + }, + { + id: "scene-2", + name: "Content", + isMain: false, + tracks: [ + { + id: "track-2", + type: "video", + isMain: true, + elements: [ + { + id: "el-2", + type: "video", + mediaId: "m2", + startTime: 0, + duration: 20, + }, + ], + }, + ], + bookmarks: [], + createdAt: "2024-02-20T14:30:00.000Z", + updatedAt: "2024-02-20T16:00:00.000Z", + }, + ], +}; \ No newline at end of file diff --git a/apps/web/src/services/storage/migrations/__tests__/fixtures/v2.ts b/apps/web/src/services/storage/migrations/__tests__/fixtures/v2.ts new file mode 100644 index 00000000..58813a9e --- /dev/null +++ b/apps/web/src/services/storage/migrations/__tests__/fixtures/v2.ts @@ -0,0 +1,121 @@ +export const v2Project = { + id: "project-v2-123", + version: 2, + metadata: { + id: "project-v2-123", + name: "My V2 Project", + thumbnail: "data:image/png;base64,abc123", + createdAt: "2024-03-01T10:00:00.000Z", + updatedAt: "2024-03-01T14:00:00.000Z", + }, + settings: { + fps: 30, + canvasSize: { width: 1920, height: 1080 }, + background: { type: "color", color: "#000000" }, + }, + currentSceneId: "scene-main", + scenes: [ + { + id: "scene-main", + name: "Main scene", + isMain: true, + tracks: [ + { + id: "track-1", + type: "video", + name: "Video Track", + isMain: true, + elements: [ + { + id: "element-1", + type: "video", + mediaId: "media-1", + startTime: 0, + duration: 15.5, + trimStart: 0, + trimEnd: 0, + }, + ], + }, + { + id: "track-2", + type: "text", + name: "Text Track", + elements: [ + { + id: "element-2", + type: "text", + content: "Hello World", + startTime: 2, + duration: 5, + }, + ], + }, + ], + bookmarks: [5.0, 10.0], + createdAt: "2024-03-01T10:00:00.000Z", + updatedAt: "2024-03-01T14:00:00.000Z", + }, + ], +}; + +export const v2ProjectWithBlurBackground = { + id: "project-v2-blur", + version: 2, + metadata: { + id: "project-v2-blur", + name: "Blur Background Project", + createdAt: "2024-03-15T08:00:00.000Z", + updatedAt: "2024-03-15T10:00:00.000Z", + }, + settings: { + fps: 24, + canvasSize: { width: 1080, height: 1920 }, + background: { type: "blur", blurIntensity: 25 }, + }, + currentSceneId: "scene-1", + scenes: [ + { + id: "scene-1", + name: "Main scene", + isMain: true, + tracks: [ + { + id: "track-1", + type: "video", + isMain: true, + elements: [ + { + id: "el-1", + type: "video", + mediaId: "m1", + startTime: 0, + duration: 30, + }, + ], + }, + ], + bookmarks: [], + createdAt: "2024-03-15T08:00:00.000Z", + updatedAt: "2024-03-15T10:00:00.000Z", + }, + ], +}; + +export const v2ProjectEmptyScenes = { + id: "project-v2-empty", + version: 2, + metadata: { + id: "project-v2-empty", + name: "Empty Scenes Project", + createdAt: "2024-04-01T00:00:00.000Z", + updatedAt: "2024-04-01T00:00:00.000Z", + }, + settings: { + fps: 30, + canvasSize: { width: 1920, height: 1080 }, + background: { type: "color", color: "#ffffff" }, + }, + currentSceneId: "", + scenes: [], +}; diff --git a/apps/web/src/services/storage/migrations/__tests__/fixtures/v3.ts b/apps/web/src/services/storage/migrations/__tests__/fixtures/v3.ts new file mode 100644 index 00000000..1d46e362 --- /dev/null +++ b/apps/web/src/services/storage/migrations/__tests__/fixtures/v3.ts @@ -0,0 +1,47 @@ +export const v3Project = { + id: "project-v3-123", + version: 3, + metadata: { + id: "project-v3-123", + name: "My V3 Project", + thumbnail: "data:image/png;base64,xyz789", + duration: 25.5, + createdAt: "2024-05-01T10:00:00.000Z", + updatedAt: "2024-05-01T14:00:00.000Z", + }, + settings: { + fps: 30, + canvasSize: { width: 1920, height: 1080 }, + background: { type: "color", color: "#000000" }, + }, + currentSceneId: "scene-main", + scenes: [ + { + id: "scene-main", + name: "Main scene", + isMain: true, + tracks: [ + { + id: "track-1", + type: "video", + name: "Video Track", + isMain: true, + elements: [ + { + id: "element-1", + type: "video", + mediaId: "media-1", + startTime: 0, + duration: 25.5, + trimStart: 0, + trimEnd: 0, + }, + ], + }, + ], + bookmarks: [], + createdAt: "2024-05-01T10:00:00.000Z", + updatedAt: "2024-05-01T14:00:00.000Z", + }, + ], +}; diff --git a/apps/web/src/services/storage/migrations/__tests__/v0-to-v1.test.ts b/apps/web/src/services/storage/migrations/__tests__/v0-to-v1.test.ts new file mode 100644 index 00000000..2cd67da8 --- /dev/null +++ b/apps/web/src/services/storage/migrations/__tests__/v0-to-v1.test.ts @@ -0,0 +1,111 @@ +import { describe, expect, test } from "bun:test"; +import { getProjectId, transformProjectV0ToV1 } from "../transformers/v0-to-v1"; +import { + projectMalformed, + projectWithNoId, + v0Project, + v0ProjectEmpty, + v0ProjectWithMetadata, + v1Project, +} from "./fixtures"; + +describe("V0 to V1 Migration", () => { + const fixedDate = new Date("2024-06-01T12:00:00.000Z"); + + describe("transformProjectV0ToV1", () => { + test("adds scenes array to v0 project", () => { + const result = transformProjectV0ToV1({ + project: v0Project, + options: { now: fixedDate }, + }); + + expect(result.skipped).toBe(false); + expect(result.project.version).toBe(1); + expect(Array.isArray(result.project.scenes)).toBe(true); + expect((result.project.scenes as unknown[]).length).toBe(1); + expect(result.project.currentSceneId).toBeDefined(); + }); + + test("creates main scene with correct structure", () => { + const result = transformProjectV0ToV1({ + project: v0Project, + options: { now: fixedDate }, + }); + + const scenes = result.project.scenes as Array>; + const mainScene = scenes[0]; + + expect(mainScene.isMain).toBe(true); + expect(mainScene.name).toBe("Main scene"); + expect(typeof mainScene.id).toBe("string"); + expect(Array.isArray(mainScene.tracks)).toBe(true); + expect(Array.isArray(mainScene.bookmarks)).toBe(true); + }); + + test("updates metadata.updatedAt when metadata exists", () => { + const result = transformProjectV0ToV1({ + project: v0ProjectWithMetadata, + options: { now: fixedDate }, + }); + + const metadata = result.project.metadata as Record; + expect(metadata.updatedAt).toBe(fixedDate.toISOString()); + }); + + test("updates root updatedAt when no metadata", () => { + const result = transformProjectV0ToV1({ + project: v0ProjectEmpty, + options: { now: fixedDate }, + }); + + expect(result.project.updatedAt).toBe(fixedDate.toISOString()); + }); + + test("skips project that already has scenes", () => { + const result = transformProjectV0ToV1({ + project: v1Project, + options: { now: fixedDate }, + }); + + expect(result.skipped).toBe(true); + expect(result.reason).toBe("already has scenes"); + }); + + test("preserves original project properties", () => { + const result = transformProjectV0ToV1({ + project: v0Project, + options: { now: fixedDate }, + }); + + expect(result.project.id).toBe(v0Project.id); + expect(result.project.name).toBe(v0Project.name); + expect(result.project.fps).toBe(v0Project.fps); + expect(result.project.canvasSize).toEqual(v0Project.canvasSize); + }); + }); + + describe("getProjectId", () => { + test("returns id from root level", () => { + const id = getProjectId({ project: v0Project }); + expect(id).toBe("project-v0-123"); + }); + + test("returns id from metadata when root id missing", () => { + const project = { + metadata: { id: "from-metadata" }, + }; + const id = getProjectId({ project }); + expect(id).toBe("from-metadata"); + }); + + test("returns null when no id found", () => { + const id = getProjectId({ project: projectWithNoId }); + expect(id).toBe(null); + }); + + test("returns null for malformed project", () => { + const id = getProjectId({ project: projectMalformed }); + expect(id).toBe("project-malformed"); + }); + }); +}); diff --git a/apps/web/src/services/storage/migrations/__tests__/v1-to-v2.test.ts b/apps/web/src/services/storage/migrations/__tests__/v1-to-v2.test.ts new file mode 100644 index 00000000..ff6a31c2 --- /dev/null +++ b/apps/web/src/services/storage/migrations/__tests__/v1-to-v2.test.ts @@ -0,0 +1,165 @@ +import { describe, expect, test } from "bun:test"; +import { + DEFAULT_BLUR_INTENSITY, + DEFAULT_CANVAS_SIZE, + DEFAULT_COLOR, + DEFAULT_FPS, +} from "@/constants/project-constants"; +import { getProjectId, transformProjectV1ToV2 } from "../transformers/v1-to-v2"; +import { + projectWithNoId, + projectWithNullValues, + v1Project, + v1ProjectWithMultipleScenes, + v2Project, +} from "./fixtures"; + +describe("V1 to V2 Migration", () => { + describe("transformProjectV1ToV2", () => { + test("creates metadata object from flat properties", () => { + const result = transformProjectV1ToV2({ project: v1Project }); + + expect(result.skipped).toBe(false); + expect(result.project.version).toBe(2); + + const metadata = result.project.metadata as Record; + expect(metadata.id).toBe(v1Project.id); + expect(metadata.name).toBe(v1Project.name); + expect(typeof metadata.createdAt).toBe("string"); + expect(typeof metadata.updatedAt).toBe("string"); + }); + + test("creates settings object from flat properties", () => { + const result = transformProjectV1ToV2({ project: v1Project }); + + const settings = result.project.settings as Record; + expect(settings.fps).toBe(v1Project.fps); + expect(settings.canvasSize).toEqual(v1Project.canvasSize); + }); + + test("converts color background correctly", () => { + const result = transformProjectV1ToV2({ project: v1Project }); + + const settings = result.project.settings as Record; + const background = settings.background as Record; + expect(background.type).toBe("color"); + expect(background.color).toBe(v1Project.backgroundColor); + }); + + test("converts blur background correctly", () => { + const projectWithBlur = { + ...v1Project, + backgroundType: "blur", + blurIntensity: 30, + }; + const result = transformProjectV1ToV2({ project: projectWithBlur }); + + const settings = result.project.settings as Record; + const background = settings.background as Record; + expect(background.type).toBe("blur"); + expect(background.blurIntensity).toBe(30); + }); + + test("applies legacy bookmarks to main scene", () => { + const result = transformProjectV1ToV2({ project: v1Project }); + + const scenes = result.project.scenes as Array>; + const mainScene = scenes.find((s) => s.isMain === true); + expect(mainScene?.bookmarks).toEqual(v1Project.bookmarks); + }); + + test("preserves existing scene bookmarks", () => { + const result = transformProjectV1ToV2({ + project: v1ProjectWithMultipleScenes, + }); + + const scenes = result.project.scenes as Array>; + const introScene = scenes.find((s) => s.name === "Intro"); + expect(introScene?.bookmarks).toEqual([1.0]); + }); + + test("skips project that already has v2 structure", () => { + const result = transformProjectV1ToV2({ project: v2Project }); + + expect(result.skipped).toBe(true); + expect(result.reason).toBe("already v2"); + }); + + test("skips project with no id", () => { + const result = transformProjectV1ToV2({ project: projectWithNoId }); + + expect(result.skipped).toBe(true); + expect(result.reason).toBe("no project id"); + }); + + test("handles null values gracefully", () => { + const result = transformProjectV1ToV2({ project: projectWithNullValues }); + + expect(result.skipped).toBe(false); + const settings = result.project.settings as Record; + expect(settings.fps).toBe(DEFAULT_FPS); + expect(settings.canvasSize).toEqual(DEFAULT_CANVAS_SIZE); + }); + + test("uses default values for missing properties", () => { + const minimalProject = { + id: "minimal", + version: 1, + scenes: [], + }; + const result = transformProjectV1ToV2({ project: minimalProject }); + + const settings = result.project.settings as Record; + expect(settings.fps).toBe(DEFAULT_FPS); + expect(settings.canvasSize).toEqual(DEFAULT_CANVAS_SIZE); + + const background = settings.background as Record; + expect(background.type).toBe("color"); + expect(background.color).toBe(DEFAULT_COLOR); + }); + + test("uses default blur intensity when missing", () => { + const projectWithBlurNoIntensity = { + id: "blur-no-intensity", + version: 1, + backgroundType: "blur", + scenes: [], + }; + const result = transformProjectV1ToV2({ + project: projectWithBlurNoIntensity, + }); + + const settings = result.project.settings as Record; + const background = settings.background as Record; + expect(background.blurIntensity).toBe(DEFAULT_BLUR_INTENSITY); + }); + + test("preserves currentSceneId", () => { + const result = transformProjectV1ToV2({ project: v1Project }); + expect(result.project.currentSceneId).toBe(v1Project.currentSceneId); + }); + + test("finds main scene id when currentSceneId missing", () => { + const projectWithoutCurrentScene = { + ...v1Project, + currentSceneId: undefined, + }; + const result = transformProjectV1ToV2({ + project: projectWithoutCurrentScene, + }); + expect(result.project.currentSceneId).toBe("scene-main"); + }); + }); + + describe("getProjectId", () => { + test("returns id from root level", () => { + const id = getProjectId({ project: v1Project }); + expect(id).toBe("project-v1-123"); + }); + + test("returns id from metadata", () => { + const id = getProjectId({ project: v1ProjectWithMultipleScenes }); + expect(id).toBe("project-v1-multi"); + }); + }); +}); diff --git a/apps/web/src/services/storage/migrations/__tests__/v2-to-v3.test.ts b/apps/web/src/services/storage/migrations/__tests__/v2-to-v3.test.ts new file mode 100644 index 00000000..6e2991d0 --- /dev/null +++ b/apps/web/src/services/storage/migrations/__tests__/v2-to-v3.test.ts @@ -0,0 +1,175 @@ +import { describe, expect, test } from "bun:test"; +import { getProjectId, transformProjectV2ToV3 } from "../transformers/v2-to-v3"; +import { + projectWithNoId, + v2Project, + v2ProjectEmptyScenes, + v2ProjectWithBlurBackground, + v3Project, +} from "./fixtures"; + +describe("V2 to V3 Migration", () => { + describe("transformProjectV2ToV3", () => { + test("adds duration to metadata", () => { + const result = transformProjectV2ToV3({ project: v2Project }); + + expect(result.skipped).toBe(false); + expect(result.project.version).toBe(3); + + const metadata = result.project.metadata as Record; + expect(typeof metadata.duration).toBe("number"); + }); + + test("calculates duration from scene tracks", () => { + const result = transformProjectV2ToV3({ project: v2Project }); + + const metadata = result.project.metadata as Record; + // v2Project has a video element with duration 15.5 and a text element at startTime 2 with duration 5 + // Total duration should be max(15.5, 2+5) = 15.5 + expect(metadata.duration).toBe(15.5); + }); + + test("handles project with blur background", () => { + const result = transformProjectV2ToV3({ + project: v2ProjectWithBlurBackground, + }); + + expect(result.skipped).toBe(false); + const metadata = result.project.metadata as Record; + // v2ProjectWithBlurBackground has a video with duration 30 + expect(metadata.duration).toBe(30); + }); + + test("handles empty scenes with zero duration", () => { + const result = transformProjectV2ToV3({ project: v2ProjectEmptyScenes }); + + expect(result.skipped).toBe(false); + const metadata = result.project.metadata as Record; + expect(metadata.duration).toBe(0); + }); + + test("skips project that already has v3 structure", () => { + const result = transformProjectV2ToV3({ project: v3Project }); + + expect(result.skipped).toBe(true); + expect(result.reason).toBe("already v3"); + }); + + test("skips project that has duration in metadata", () => { + const projectWithDuration = { + ...v2Project, + version: 2, + metadata: { + ...v2Project.metadata, + duration: 10, + }, + }; + const result = transformProjectV2ToV3({ project: projectWithDuration }); + + expect(result.skipped).toBe(true); + expect(result.reason).toBe("already v3"); + }); + + test("skips project with no id", () => { + const result = transformProjectV2ToV3({ project: projectWithNoId }); + + expect(result.skipped).toBe(true); + expect(result.reason).toBe("no project id"); + }); + + test("preserves existing metadata fields", () => { + const result = transformProjectV2ToV3({ project: v2Project }); + + const metadata = result.project.metadata as Record; + expect(metadata.id).toBe(v2Project.metadata.id); + expect(metadata.name).toBe(v2Project.metadata.name); + expect(metadata.thumbnail).toBe(v2Project.metadata.thumbnail); + expect(metadata.createdAt).toBe(v2Project.metadata.createdAt); + expect(metadata.updatedAt).toBe(v2Project.metadata.updatedAt); + }); + + test("preserves settings object", () => { + const result = transformProjectV2ToV3({ project: v2Project }); + + expect(result.project.settings).toEqual(v2Project.settings); + }); + + test("preserves scenes array", () => { + const result = transformProjectV2ToV3({ project: v2Project }); + + expect(result.project.scenes).toEqual(v2Project.scenes); + }); + + test("handles project without metadata object", () => { + const projectWithoutMetadata = { + id: "no-metadata", + version: 2, + scenes: [], + }; + const result = transformProjectV2ToV3({ + project: projectWithoutMetadata, + }); + + expect(result.skipped).toBe(false); + const metadata = result.project.metadata as Record; + expect(metadata.duration).toBe(0); + }); + + test("calculates duration from main scene only", () => { + const multiSceneProject = { + id: "multi-scene", + version: 2, + metadata: { id: "multi-scene", name: "Multi" }, + scenes: [ + { + id: "scene-1", + isMain: true, + tracks: [ + { + type: "video", + elements: [{ startTime: 0, duration: 10 }], + }, + ], + }, + { + id: "scene-2", + isMain: false, + tracks: [ + { + type: "video", + elements: [{ startTime: 0, duration: 20 }], + }, + ], + }, + ], + }; + const result = transformProjectV2ToV3({ project: multiSceneProject }); + + const metadata = result.project.metadata as Record; + // Duration is from main scene only, not sum of all scenes + expect(metadata.duration).toBe(10); + }); + }); + + describe("getProjectId", () => { + test("returns id from root level", () => { + const projectWithRootId = { id: "root-id", metadata: {} }; + const id = getProjectId({ project: projectWithRootId }); + expect(id).toBe("root-id"); + }); + + test("returns id from metadata when root id missing", () => { + const id = getProjectId({ project: v2Project }); + expect(id).toBe("project-v2-123"); + }); + + test("prefers root id over metadata id", () => { + const projectWithBothIds = { + id: "root-id", + metadata: { id: "metadata-id" }, + }; + const id = getProjectId({ project: projectWithBothIds }); + expect(id).toBe("root-id"); + }); + }); +}); 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; + +export interface MigrationResult { + 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 { + 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 { + 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 { + 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; +} diff --git a/apps/web/src/services/storage/migrations/v0-to-v1.ts b/apps/web/src/services/storage/migrations/v0-to-v1.ts index 7ec388f1..18e15180 100644 --- a/apps/web/src/services/storage/migrations/v0-to-v1.ts +++ b/apps/web/src/services/storage/migrations/v0-to-v1.ts @@ -1,10 +1,6 @@ import { IndexedDBAdapter } from "@/services/storage/indexeddb-adapter"; -import type { SerializedScene } from "@/services/storage/types"; -import { buildDefaultScene } from "@/lib/scenes"; -import type { TScene } from "@/types/timeline"; import { StorageMigration } from "./base"; - -type ProjectRecord = Record; +import { getProjectId, transformProjectV0ToV1 } from "./transformers/v0-to-v1"; export class V0toV1Migration extends StorageMigration { from = 0; @@ -19,75 +15,24 @@ export class V0toV1Migration extends StorageMigration { const projects = await projectsAdapter.getAll(); for (const project of projects) { - if (!isRecord(project)) { + if (typeof project !== "object" || project === null) { continue; } - const scenesValue = project.scenes; - if (Array.isArray(scenesValue) && scenesValue.length > 0) { + const result = transformProjectV0ToV1({ + project: project as Record, + }); + + if (result.skipped) { continue; } - 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 = new Date().toISOString(); - if (isRecord(project.metadata)) { - updatedProject.metadata = { - ...project.metadata, - updatedAt, - }; - } else { - updatedProject.updatedAt = updatedAt; - } - - const projectId = getProjectId({ project: updatedProject }); + const projectId = getProjectId({ project: result.project }); if (!projectId) { continue; } - await projectsAdapter.set(projectId, updatedProject); + await projectsAdapter.set(projectId, result.project); } } } - -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 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 isRecord(value: unknown): value is ProjectRecord { - return typeof value === "object" && value !== null; -} diff --git a/apps/web/src/services/storage/migrations/v1-to-v2.ts b/apps/web/src/services/storage/migrations/v1-to-v2.ts index 05d302ea..7519c6ac 100644 --- a/apps/web/src/services/storage/migrations/v1-to-v2.ts +++ b/apps/web/src/services/storage/migrations/v1-to-v2.ts @@ -1,13 +1,6 @@ -import { - DEFAULT_BLUR_INTENSITY, - DEFAULT_CANVAS_SIZE, - DEFAULT_COLOR, - DEFAULT_FPS, -} from "@/constants/project-constants"; import { IndexedDBAdapter } from "@/services/storage/indexeddb-adapter"; import { StorageMigration } from "./base"; - -type ProjectRecord = Record; +import { getProjectId, transformProjectV1ToV2 } from "./transformers/v1-to-v2"; export class V1toV2Migration extends StorageMigration { from = 1; @@ -22,318 +15,24 @@ export class V1toV2Migration extends StorageMigration { const projects = await projectsAdapter.getAll(); for (const project of projects) { - if (!isRecord(project)) { + if (typeof project !== "object" || project === null) { continue; } - const projectId = getProjectId({ project }); + const result = transformProjectV1ToV2({ + project: project as Record, + }); + + if (result.skipped) { + continue; + } + + const projectId = getProjectId({ project: result.project }); if (!projectId) { continue; } - if (isV2Project({ project })) { - continue; - } - - const migratedProject = migrateProject({ project, projectId }); - await projectsAdapter.set(projectId, migratedProject); + await projectsAdapter.set(projectId, result.project); } } } - -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, - }; -} - -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/v2-to-v3.ts b/apps/web/src/services/storage/migrations/v2-to-v3.ts index eb6db26d..100dc572 100644 --- a/apps/web/src/services/storage/migrations/v2-to-v3.ts +++ b/apps/web/src/services/storage/migrations/v2-to-v3.ts @@ -1,9 +1,6 @@ -import { getProjectDurationFromScenes } from "@/lib/scenes"; import { IndexedDBAdapter } from "@/services/storage/indexeddb-adapter"; -import type { TScene } from "@/types/timeline"; import { StorageMigration } from "./base"; - -type ProjectRecord = Record; +import { getProjectId, transformProjectV2ToV3 } from "./transformers/v2-to-v3"; export class V2toV3Migration extends StorageMigration { from = 2; @@ -18,75 +15,24 @@ export class V2toV3Migration extends StorageMigration { const projects = await projectsAdapter.getAll(); for (const project of projects) { - if (!isRecord(project)) { + if (typeof project !== "object" || project === null) { continue; } - const projectId = getProjectId({ project }); + const result = transformProjectV2ToV3({ + project: project as Record, + }); + + if (result.skipped) { + continue; + } + + const projectId = getProjectId({ project: result.project }); if (!projectId) { continue; } - if (isV3Project({ project })) { - continue; - } - - 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, - }; - - await projectsAdapter.set(projectId, migratedProject); + await projectsAdapter.set(projectId, result.project); } } } - -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; -} diff --git a/package.json b/package.json index 9e4a07dc..be5d523e 100644 --- a/package.json +++ b/package.json @@ -13,7 +13,8 @@ "start:tools": "turbo run start --filter=@opencut/tools", "lint:web": "biome lint apps/web/src --max-diagnostics=1000", "lint:web:fix": "biome lint apps/web/src --write --max-diagnostics=1000", - "format:web": "biome format apps/web/src/services/renderer --write --max-diagnostics=1000" + "format:web": "biome format apps/web/src/services/renderer --write --max-diagnostics=1000", + "test": "bun test" }, "dependencies": { "@types/react": "^19.2.10",