summaryrefslogtreecommitdiff
path: root/apps/web/src/services/storage/migrations
diff options
context:
space:
mode:
Diffstat (limited to 'apps/web/src/services/storage/migrations')
-rw-r--r--apps/web/src/services/storage/migrations/base.ts5
-rw-r--r--apps/web/src/services/storage/migrations/index.ts14
-rw-r--r--apps/web/src/services/storage/migrations/runner.ts134
-rw-r--r--apps/web/src/services/storage/migrations/v0-to-v1.ts93
-rw-r--r--apps/web/src/services/storage/migrations/v1-to-v2.ts339
-rw-r--r--apps/web/src/services/storage/migrations/v2-to-v3.ts92
-rw-r--r--apps/web/src/services/storage/migrations/version-manager.ts73
7 files changed, 750 insertions, 0 deletions
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<void>;
+}
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<string, unknown>;
+
+export async function runStorageMigrations({
+ migrations,
+ versionManager = new StorageVersionManager(),
+ callbacks,
+}: {
+ migrations: StorageMigration[];
+ versionManager?: StorageVersionManager;
+ callbacks?: StorageMigrationCallbacks;
+}): Promise<StorageMigrationResult> {
+ 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<number> {
+ const projectsAdapter = new IndexedDBAdapter<unknown>(
+ "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<string, unknown>;
+
+export class V0toV1Migration extends StorageMigration {
+ from = 0;
+ to = 1;
+
+ async run(): Promise<void> {
+ const projectsAdapter = new IndexedDBAdapter<unknown>(
+ "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<string, unknown>;
+
+export class V1toV2Migration extends StorageMigration {
+ from = 1;
+ to = 2;
+
+ async run(): Promise<void> {
+ const projectsAdapter = new IndexedDBAdapter<unknown>(
+ "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<string, unknown>;
+
+export class V2toV3Migration extends StorageMigration {
+ from = 2;
+ to = 3;
+
+ async run(): Promise<void> {
+ const projectsAdapter = new IndexedDBAdapter<unknown>(
+ "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<StorageVersionRecord>;
+
+ constructor({
+ dbName = DEFAULT_DB_NAME,
+ storeName = DEFAULT_STORE_NAME,
+ version = DEFAULT_DB_VERSION,
+ }: {
+ dbName?: string;
+ storeName?: string;
+ version?: number;
+ } = {}) {
+ this.adapter = new IndexedDBAdapter<StorageVersionRecord>(
+ dbName,
+ storeName,
+ version,
+ );
+ }
+
+ async getVersion(): Promise<number> {
+ const record = await this.adapter.get(STORAGE_VERSION_KEY);
+ return record?.version ?? 0;
+ }
+
+ async getVersionRecord(): Promise<StorageVersionRecord | null> {
+ return this.adapter.get(STORAGE_VERSION_KEY);
+ }
+
+ async setVersion({ version }: { version: number }): Promise<void> {
+ 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<void> {
+ const record = await this.getVersionRecord();
+ const version = record?.version ?? 0;
+ await this.adapter.set(STORAGE_VERSION_KEY, {
+ version,
+ inProgress: { from, to },
+ });
+ }
+
+ async clearInProgress(): Promise<void> {
+ const record = await this.getVersionRecord();
+ const version = record?.version ?? 0;
+ await this.adapter.set(STORAGE_VERSION_KEY, { version });
+ }
+}