From d55d2583a71ae35fa8d0d1bfbec975e67e66aafa Mon Sep 17 00:00:00 2001 From: cashmere Date: Mon, 27 Jul 2026 01:21:20 +0200 Subject: feat: git-like project versioning (v1) Linear per-project history per notes/project-versioning.md: named checkpoints (thumbnail via renderer.captureFrame) plus auto-checkpoints on save (60s throttle, ring of 20, named never rotate). Restore writes a safety checkpoint first, then swaps the document outside undo/redo. Media assets referenced by any version are pinned against deletion. Migration v31-v32 stamps the schema; the version store lives in its own IDB database with an in-memory fallback for tests. Versions popover in the editor header for jump/rename/delete. 12 new tests. --- .../migrations/__tests__/v31-to-v32.test.ts | 65 ++++++++++++++++++++++ 1 file changed, 65 insertions(+) create mode 100644 apps/web/src/services/storage/migrations/__tests__/v31-to-v32.test.ts (limited to 'apps/web/src/services/storage/migrations/__tests__') 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); + }); +}); -- cgit v1.3.1