diff options
Diffstat (limited to 'apps/web/src/services')
5 files changed, 200 insertions, 72 deletions
diff --git a/apps/web/src/services/storage/migrations/__tests__/v31-to-v32.test.ts b/apps/web/src/services/storage/migrations/__tests__/v31-to-v32.test.ts new file mode 100644 index 00000000..e3f1af8c --- /dev/null +++ b/apps/web/src/services/storage/migrations/__tests__/v31-to-v32.test.ts @@ -0,0 +1,65 @@ +import { describe, expect, test } from "bun:test"; +import { transformProjectV31ToV32 } from "../transformers/v31-to-v32"; +import { asRecord, asRecordArray } from "./helpers"; + +describe("V31 to V32 Migration", () => { + test("stamps a v31 project as v32 without changing its content", () => { + const result = transformProjectV31ToV32({ + project: { + id: "project-v31", + version: 31, + metadata: { id: "project-v31", name: "My project" }, + scenes: [ + { + id: "scene-1", + tracks: { + main: { + id: "track-1", + type: "video", + elements: [{ id: "elem-1", type: "video", mediaId: "media-1" }], + }, + overlay: [], + audio: [], + }, + }, + ], + }, + }); + + expect(result.skipped).toBe(false); + expect(result.project.version).toBe(32); + const scene = asRecordArray(result.project.scenes)[0]; + const main = asRecord(asRecord(scene.tracks).main); + const element = asRecordArray(main.elements)[0]; + expect(element).toMatchObject({ + id: "elem-1", + type: "video", + mediaId: "media-1", + }); + expect(asRecord(result.project.metadata).name).toBe("My project"); + }); + + test("skips a project that is already v32", () => { + const project = { id: "p1", version: 32, scenes: [] }; + const result = transformProjectV31ToV32({ project }); + expect(result.skipped).toBe(true); + expect(result.reason).toBe("already v32"); + expect(result.project).toBe(project); + }); + + test("skips a project that is not v31", () => { + const project = { id: "p1", version: 30, scenes: [] }; + const result = transformProjectV31ToV32({ project }); + expect(result.skipped).toBe(true); + expect(result.reason).toBe("not v31"); + expect(result.project).toBe(project); + }); + + test("skips a project without an id", () => { + const project = { version: 31, scenes: [] }; + const result = transformProjectV31ToV32({ project }); + expect(result.skipped).toBe(true); + expect(result.reason).toBe("no project id"); + expect(result.project).toBe(project); + }); +}); diff --git a/apps/web/src/services/storage/migrations/index.ts b/apps/web/src/services/storage/migrations/index.ts index cbc35bb3..f3f2a22c 100644 --- a/apps/web/src/services/storage/migrations/index.ts +++ b/apps/web/src/services/storage/migrations/index.ts @@ -30,10 +30,11 @@ import { V27toV28Migration } from "./v27-to-v28"; import { V28toV29Migration } from "./v28-to-v29"; import { V29toV30Migration } from "./v29-to-v30"; import { V30toV31Migration } from "./v30-to-v31"; +import { V31toV32Migration } from "./v31-to-v32"; export { runStorageMigrations } from "./runner"; export type { MigrationProgress } from "./runner"; -export const CURRENT_PROJECT_VERSION = 31; +export const CURRENT_PROJECT_VERSION = 32; export const migrations = [ new V0toV1Migration(), @@ -67,4 +68,5 @@ export const migrations = [ new V28toV29Migration(), new V29toV30Migration(), new V30toV31Migration(), + new V31toV32Migration(), ]; diff --git a/apps/web/src/services/storage/migrations/transformers/v31-to-v32.ts b/apps/web/src/services/storage/migrations/transformers/v31-to-v32.ts new file mode 100644 index 00000000..bedb4784 --- /dev/null +++ b/apps/web/src/services/storage/migrations/transformers/v31-to-v32.ts @@ -0,0 +1,31 @@ +import type { MigrationResult, ProjectRecord } from "./types"; +import { getProjectId } from "./utils"; + +/** + * v31 → v32 introduces the `projectVersions` store (named checkpoints + + * auto-checkpoints; see notes/project-versioning.md). The store lives in its + * own IndexedDB database created lazily by the versions adapter, so project + * records need no shape change — this migration only stamps the new version. + */ +export function transformProjectV31ToV32({ + project, +}: { + project: ProjectRecord; +}): MigrationResult<ProjectRecord> { + if (!getProjectId({ project })) { + return { project, skipped: true, reason: "no project id" }; + } + + const version = project.version; + if (typeof version !== "number") { + return { project, skipped: true, reason: "invalid version" }; + } + if (version >= 32) { + return { project, skipped: true, reason: "already v32" }; + } + if (version !== 31) { + return { project, skipped: true, reason: "not v31" }; + } + + return { project: { ...project, version: 32 }, skipped: false }; +} diff --git a/apps/web/src/services/storage/migrations/v31-to-v32.ts b/apps/web/src/services/storage/migrations/v31-to-v32.ts new file mode 100644 index 00000000..0b6da324 --- /dev/null +++ b/apps/web/src/services/storage/migrations/v31-to-v32.ts @@ -0,0 +1,14 @@ +import { StorageMigration, type StorageMigrationRunArgs } from "./base"; +import type { MigrationResult, ProjectRecord } from "./transformers/types"; +import { transformProjectV31ToV32 } from "./transformers/v31-to-v32"; + +export class V31toV32Migration extends StorageMigration { + from = 31; + to = 32; + + async run({ + project, + }: StorageMigrationRunArgs): Promise<MigrationResult<ProjectRecord>> { + return transformProjectV31ToV32({ project }); + } +} diff --git a/apps/web/src/services/storage/service.ts b/apps/web/src/services/storage/service.ts index e8fc26c0..7b01f9e5 100644 --- a/apps/web/src/services/storage/service.ts +++ b/apps/web/src/services/storage/service.ts @@ -51,6 +51,91 @@ function normalizeBookmarks({ raw }: { raw: unknown }): Bookmark[] { .filter((b): b is Bookmark => b !== null); } +function stripAudioBuffers({ tracks }: { tracks: SceneTracks }): SceneTracks { + return { + ...tracks, + audio: tracks.audio.map((track) => ({ + ...track, + elements: track.elements.map((element) => { + const { buffer: _buffer, ...rest } = element; + return rest; + }), + })), + }; +} + +export function serializeProject({ + project, +}: { + project: TProject; +}): SerializedProject { + const duration = + project.metadata.duration ?? + getProjectDurationFromScenes({ scenes: project.scenes }); + const serializedScenes: SerializedScene[] = project.scenes.map((scene) => ({ + id: scene.id, + name: scene.name, + isMain: scene.isMain, + tracks: stripAudioBuffers({ tracks: scene.tracks }), + bookmarks: scene.bookmarks, + createdAt: scene.createdAt.toISOString(), + updatedAt: scene.updatedAt.toISOString(), + })); + + return { + metadata: { + id: project.metadata.id, + name: project.metadata.name, + thumbnail: project.metadata.thumbnail, + duration, + createdAt: project.metadata.createdAt.toISOString(), + updatedAt: project.metadata.updatedAt.toISOString(), + }, + scenes: serializedScenes, + currentSceneId: project.currentSceneId, + settings: project.settings, + version: project.version, + timelineViewState: project.timelineViewState, + }; +} + +export function deserializeProject({ + serialized, +}: { + serialized: SerializedProject; +}): TProject { + const scenes = + serialized.scenes?.map((scene) => ({ + id: scene.id, + name: scene.name, + isMain: scene.isMain, + tracks: scene.tracks, + bookmarks: normalizeBookmarks({ raw: scene.bookmarks }), + createdAt: new Date(scene.createdAt), + updatedAt: new Date(scene.updatedAt), + })) ?? []; + + return { + metadata: { + id: serialized.metadata.id, + name: serialized.metadata.name, + thumbnail: serialized.metadata.thumbnail, + duration: roundMediaTime({ + time: + serialized.metadata.duration ?? + getProjectDurationFromScenes({ scenes }), + }), + createdAt: new Date(serialized.metadata.createdAt), + updatedAt: new Date(serialized.metadata.updatedAt), + }, + scenes, + currentSceneId: serialized.currentSceneId || "", + settings: serialized.settings, + version: serialized.version, + timelineViewState: serialized.timelineViewState, + }; +} + class StorageService { private projectsAdapter: IndexedDBAdapter<SerializedProject>; private savedSoundsAdapter: IndexedDBAdapter<SavedSoundsData>; @@ -118,48 +203,8 @@ class StorageService { return isStorageQuotaExceededError({ error }); } - private stripAudioBuffers({ tracks }: { tracks: SceneTracks }): SceneTracks { - return { - ...tracks, - audio: tracks.audio.map((track) => ({ - ...track, - elements: track.elements.map((element) => { - const { buffer: _buffer, ...rest } = element; - return rest; - }), - })), - }; - } - async saveProject({ project }: { project: TProject }): Promise<void> { - const duration = - project.metadata.duration ?? - getProjectDurationFromScenes({ scenes: project.scenes }); - const serializedScenes: SerializedScene[] = project.scenes.map((scene) => ({ - id: scene.id, - name: scene.name, - isMain: scene.isMain, - tracks: this.stripAudioBuffers({ tracks: scene.tracks }), - bookmarks: scene.bookmarks, - createdAt: scene.createdAt.toISOString(), - updatedAt: scene.updatedAt.toISOString(), - })); - - const serializedProject: SerializedProject = { - metadata: { - id: project.metadata.id, - name: project.metadata.name, - thumbnail: project.metadata.thumbnail, - duration, - createdAt: project.metadata.createdAt.toISOString(), - updatedAt: project.metadata.updatedAt.toISOString(), - }, - scenes: serializedScenes, - currentSceneId: project.currentSceneId, - settings: project.settings, - version: project.version, - timelineViewState: project.timelineViewState, - }; + const serializedProject = serializeProject({ project }); await this.projectsAdapter.set({ key: project.metadata.id, @@ -190,36 +235,7 @@ class StorageService { return null; } - const scenes = - serializedProject.scenes?.map((scene) => ({ - id: scene.id, - name: scene.name, - isMain: scene.isMain, - tracks: scene.tracks, - bookmarks: normalizeBookmarks({ raw: scene.bookmarks }), - createdAt: new Date(scene.createdAt), - updatedAt: new Date(scene.updatedAt), - })) ?? []; - - const project: TProject = { - metadata: { - id: serializedProject.metadata.id, - name: serializedProject.metadata.name, - thumbnail: serializedProject.metadata.thumbnail, - duration: roundMediaTime({ - time: - serializedProject.metadata.duration ?? - getProjectDurationFromScenes({ scenes }), - }), - createdAt: new Date(serializedProject.metadata.createdAt), - updatedAt: new Date(serializedProject.metadata.updatedAt), - }, - scenes, - currentSceneId: serializedProject.currentSceneId || "", - settings: serializedProject.settings, - version: serializedProject.version, - timelineViewState: serializedProject.timelineViewState, - }; + const project = deserializeProject({ serialized: serializedProject }); return { project }; } |
