add tests for storage migrations

This commit is contained in:
Maze Winther 2026-02-01 12:15:37 +01:00
parent 03c2b16064
commit 2759d7ed31
17 changed files with 1365 additions and 444 deletions

View File

@ -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";

View File

@ -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
};

View File

@ -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",
},
],
};

View File

@ -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: [],
};

View File

@ -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",
},
],
};

View File

@ -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<Record<string, unknown>>;
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<string, unknown>;
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");
});
});
});

View File

@ -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<string, unknown>;
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<string, unknown>;
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<string, unknown>;
const background = settings.background as Record<string, unknown>;
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<string, unknown>;
const background = settings.background as Record<string, unknown>;
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<Record<string, unknown>>;
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<Record<string, unknown>>;
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<string, unknown>;
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<string, unknown>;
expect(settings.fps).toBe(DEFAULT_FPS);
expect(settings.canvasSize).toEqual(DEFAULT_CANVAS_SIZE);
const background = settings.background as Record<string, unknown>;
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<string, unknown>;
const background = settings.background as Record<string, unknown>;
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");
});
});
});

View File

@ -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<string, unknown>;
expect(typeof metadata.duration).toBe("number");
});
test("calculates duration from scene tracks", () => {
const result = transformProjectV2ToV3({ project: v2Project });
const metadata = result.project.metadata as Record<string, unknown>;
// 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<string, unknown>;
// 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<string, unknown>;
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<string, unknown>;
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<string, unknown>;
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<string, unknown>;
// 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");
});
});
});

View File

@ -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";

View File

@ -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;
}

View File

@ -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;
}

View File

@ -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;
}

View File

@ -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;
}

View File

@ -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<string, unknown>;
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<string, unknown>,
});
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;
}

View File

@ -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<string, unknown>;
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<string, unknown>,
});
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;
}

View File

@ -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<string, unknown>;
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<string, unknown>,
});
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;
}

View File

@ -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",