From 7bf0984698e43b9aa2dd879f65951318ed410390 Mon Sep 17 00:00:00 2001 From: Maze <167211895+mazeincoding@users.noreply.github.com> Date: Sat, 31 Jan 2026 00:20:04 +0100 Subject: codebase overhaul (#697) --- apps/web/src/services/storage/migrations/base.ts | 5 + apps/web/src/services/storage/migrations/index.ts | 14 + apps/web/src/services/storage/migrations/runner.ts | 134 ++++++++ .../src/services/storage/migrations/v0-to-v1.ts | 93 ++++++ .../src/services/storage/migrations/v1-to-v2.ts | 339 +++++++++++++++++++++ .../src/services/storage/migrations/v2-to-v3.ts | 92 ++++++ .../services/storage/migrations/version-manager.ts | 73 +++++ 7 files changed, 750 insertions(+) create mode 100644 apps/web/src/services/storage/migrations/base.ts create mode 100644 apps/web/src/services/storage/migrations/index.ts create mode 100644 apps/web/src/services/storage/migrations/runner.ts create mode 100644 apps/web/src/services/storage/migrations/v0-to-v1.ts create mode 100644 apps/web/src/services/storage/migrations/v1-to-v2.ts create mode 100644 apps/web/src/services/storage/migrations/v2-to-v3.ts create mode 100644 apps/web/src/services/storage/migrations/version-manager.ts (limited to 'apps/web/src/services/storage/migrations') diff --git a/apps/web/src/services/storage/migrations/base.ts b/apps/web/src/services/storage/migrations/base.ts new file mode 100644 index 00000000..c7558264 --- /dev/null +++ b/apps/web/src/services/storage/migrations/base.ts @@ -0,0 +1,5 @@ +export abstract class StorageMigration { + abstract from: number; + abstract to: number; + abstract run(): Promise; +} diff --git a/apps/web/src/services/storage/migrations/index.ts b/apps/web/src/services/storage/migrations/index.ts new file mode 100644 index 00000000..2343362f --- /dev/null +++ b/apps/web/src/services/storage/migrations/index.ts @@ -0,0 +1,14 @@ +export { StorageMigration } from "./base"; +export { StorageVersionManager } from "./version-manager"; +export { runStorageMigrations } from "./runner"; +import { V0toV1Migration } from "./v0-to-v1"; +import { V1toV2Migration } from "./v1-to-v2"; +import { V2toV3Migration } from "./v2-to-v3"; + +export const CURRENT_STORAGE_VERSION = 3; + +export const migrations = [ + new V0toV1Migration(), + new V1toV2Migration(), + new V2toV3Migration(), +]; diff --git a/apps/web/src/services/storage/migrations/runner.ts b/apps/web/src/services/storage/migrations/runner.ts new file mode 100644 index 00000000..d9277cb0 --- /dev/null +++ b/apps/web/src/services/storage/migrations/runner.ts @@ -0,0 +1,134 @@ +import { IndexedDBAdapter } from "@/services/storage/indexeddb-adapter"; +import type { StorageMigration } from "./base"; +import { StorageVersionManager } from "./version-manager"; + +export interface StorageMigrationResult { + fromVersion: number; + toVersion: number; + migrated: boolean; +} + +export type StorageMigrationCallbacks = { + onMigrationStart?: ({ + fromVersion, + toVersion, + }: { + fromVersion: number; + toVersion: number; + }) => void; + onMigrationComplete?: ({ + fromVersion, + toVersion, + }: { + fromVersion: number; + toVersion: number; + }) => void; +}; + +type ProjectRecord = Record; + +export async function runStorageMigrations({ + migrations, + versionManager = new StorageVersionManager(), + callbacks, +}: { + migrations: StorageMigration[]; + versionManager?: StorageVersionManager; + callbacks?: StorageMigrationCallbacks; +}): Promise { + const versionRecord = await versionManager.getVersionRecord(); + const inferredVersion = versionRecord + ? null + : await inferStorageVersionFromProjects(); + const fromVersion = + versionRecord?.inProgress?.from ?? + versionRecord?.version ?? + inferredVersion ?? + 0; + + if (!versionRecord) { + await versionManager.setVersion({ version: fromVersion }); + } + + const orderedMigrations = [...migrations].sort((a, b) => a.from - b.from); + let currentVersion = fromVersion; + + for (const migration of orderedMigrations) { + if (migration.from !== currentVersion) { + continue; + } + + await versionManager.setInProgress({ + from: migration.from, + to: migration.to, + }); + callbacks?.onMigrationStart?.({ + fromVersion: migration.from, + toVersion: migration.to, + }); + await migration.run(); + currentVersion = migration.to; + await versionManager.setVersion({ version: currentVersion }); + await versionManager.clearInProgress(); + callbacks?.onMigrationComplete?.({ + fromVersion: migration.from, + toVersion: migration.to, + }); + } + + return { + fromVersion, + toVersion: currentVersion, + migrated: currentVersion !== fromVersion, + }; +} + +async function inferStorageVersionFromProjects(): Promise { + const projectsAdapter = new IndexedDBAdapter( + "video-editor-projects", + "projects", + 1, + ); + const projects = await projectsAdapter.getAll(); + + if (projects.length === 0) { + return 0; + } + + let lowestVersion = Number.POSITIVE_INFINITY; + + for (const project of projects) { + const projectVersion = checkProjectVersion({ project }); + if (projectVersion < lowestVersion) { + lowestVersion = projectVersion; + } + } + + if (lowestVersion === Number.POSITIVE_INFINITY) { + return 0; + } + + return lowestVersion; +} + +function checkProjectVersion({ project }: { project: unknown }): number { + if (!isRecord(project)) { + return 0; + } + + const versionValue = project.version; + if (typeof versionValue === "number") { + return versionValue; + } + + const scenesValue = project.scenes; + if (Array.isArray(scenesValue) && scenesValue.length > 0) { + return 1; + } + + return 0; +} + +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 new file mode 100644 index 00000000..7ec388f1 --- /dev/null +++ b/apps/web/src/services/storage/migrations/v0-to-v1.ts @@ -0,0 +1,93 @@ +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; + +export class V0toV1Migration extends StorageMigration { + from = 0; + to = 1; + + async run(): Promise { + const projectsAdapter = new IndexedDBAdapter( + "video-editor-projects", + "projects", + 1, + ); + const projects = await projectsAdapter.getAll(); + + for (const project of projects) { + if (!isRecord(project)) { + continue; + } + + const scenesValue = project.scenes; + if (Array.isArray(scenesValue) && scenesValue.length > 0) { + 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 }); + if (!projectId) { + continue; + } + + await projectsAdapter.set(projectId, updatedProject); + } + } +} + +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 new file mode 100644 index 00000000..05d302ea --- /dev/null +++ b/apps/web/src/services/storage/migrations/v1-to-v2.ts @@ -0,0 +1,339 @@ +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; + +export class V1toV2Migration extends StorageMigration { + from = 1; + to = 2; + + async run(): Promise { + const projectsAdapter = new IndexedDBAdapter( + "video-editor-projects", + "projects", + 1, + ); + const projects = await projectsAdapter.getAll(); + + for (const project of projects) { + if (!isRecord(project)) { + continue; + } + + const projectId = getProjectId({ project }); + if (!projectId) { + continue; + } + + if (isV2Project({ project })) { + continue; + } + + const migratedProject = migrateProject({ project, projectId }); + await projectsAdapter.set(projectId, migratedProject); + } + } +} + +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 new file mode 100644 index 00000000..eb6db26d --- /dev/null +++ b/apps/web/src/services/storage/migrations/v2-to-v3.ts @@ -0,0 +1,92 @@ +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; + +export class V2toV3Migration extends StorageMigration { + from = 2; + to = 3; + + async run(): Promise { + const projectsAdapter = new IndexedDBAdapter( + "video-editor-projects", + "projects", + 1, + ); + const projects = await projectsAdapter.getAll(); + + for (const project of projects) { + if (!isRecord(project)) { + continue; + } + + const projectId = getProjectId({ 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); + } + } +} + +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/version-manager.ts b/apps/web/src/services/storage/migrations/version-manager.ts new file mode 100644 index 00000000..879498ce --- /dev/null +++ b/apps/web/src/services/storage/migrations/version-manager.ts @@ -0,0 +1,73 @@ +import { IndexedDBAdapter } from "@/services/storage/indexeddb-adapter"; + +type StorageVersionRecord = { + version: number; + inProgress?: { + from: number; + to: number; + }; +}; + +const DEFAULT_DB_NAME = "video-editor-meta"; +const DEFAULT_STORE_NAME = "storage-version"; +const DEFAULT_DB_VERSION = 1; +const STORAGE_VERSION_KEY = "storage-version"; + +export class StorageVersionManager { + private adapter: IndexedDBAdapter; + + constructor({ + dbName = DEFAULT_DB_NAME, + storeName = DEFAULT_STORE_NAME, + version = DEFAULT_DB_VERSION, + }: { + dbName?: string; + storeName?: string; + version?: number; + } = {}) { + this.adapter = new IndexedDBAdapter( + dbName, + storeName, + version, + ); + } + + async getVersion(): Promise { + const record = await this.adapter.get(STORAGE_VERSION_KEY); + return record?.version ?? 0; + } + + async getVersionRecord(): Promise { + return this.adapter.get(STORAGE_VERSION_KEY); + } + + async setVersion({ version }: { version: number }): Promise { + const record = await this.getVersionRecord(); + const inProgress = record?.inProgress; + await this.adapter.set(STORAGE_VERSION_KEY, { + version, + ...(inProgress ? { inProgress } : {}), + }); + } + + async setInProgress({ + from, + to, + }: { + from: number; + to: number; + }): Promise { + const record = await this.getVersionRecord(); + const version = record?.version ?? 0; + await this.adapter.set(STORAGE_VERSION_KEY, { + version, + inProgress: { from, to }, + }); + } + + async clearInProgress(): Promise { + const record = await this.getVersionRecord(); + const version = record?.version ?? 0; + await this.adapter.set(STORAGE_VERSION_KEY, { version }); + } +} -- cgit v1.3.1