summaryrefslogtreecommitdiff
path: root/apps/web/src/services/storage
diff options
context:
space:
mode:
Diffstat (limited to 'apps/web/src/services/storage')
-rw-r--r--apps/web/src/services/storage/migrations/__tests__/v31-to-v32.test.ts65
-rw-r--r--apps/web/src/services/storage/migrations/index.ts4
-rw-r--r--apps/web/src/services/storage/migrations/transformers/v31-to-v32.ts31
-rw-r--r--apps/web/src/services/storage/migrations/v31-to-v32.ts14
-rw-r--r--apps/web/src/services/storage/service.ts158
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 };
}