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. --- apps/web/src/versions/store.ts | 86 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 86 insertions(+) create mode 100644 apps/web/src/versions/store.ts (limited to 'apps/web/src/versions/store.ts') diff --git a/apps/web/src/versions/store.ts b/apps/web/src/versions/store.ts new file mode 100644 index 00000000..ae3f5d11 --- /dev/null +++ b/apps/web/src/versions/store.ts @@ -0,0 +1,86 @@ +import { IndexedDBAdapter } from "@/services/storage/indexeddb-adapter"; +import type { ProjectVersion } from "./types"; + +/** + * Persistence backend for project versions. The IndexedDB implementation is + * the production store (its own database, created lazily — the v31→v32 + * migration stamps project records); the in-memory implementation backs + * unit tests. + */ +export interface VersionStore { + put({ version }: { version: ProjectVersion }): Promise; + getAllForProject({ + projectId, + }: { + projectId: string; + }): Promise; + delete({ id }: { id: string }): Promise; + deleteForProject({ projectId }: { projectId: string }): Promise; +} + +export class IndexedDBVersionStore implements VersionStore { + private adapter = new IndexedDBAdapter({ + dbName: "video-editor-project-versions", + storeName: "project-versions", + version: 1, + }); + + async put({ version }: { version: ProjectVersion }): Promise { + await this.adapter.set({ key: version.id, value: version }); + } + + async getAllForProject({ + projectId, + }: { + projectId: string; + }): Promise { + const all = await this.adapter.getAll(); + return all.filter( + (version) => + typeof version === "object" && + version !== null && + version.projectId === projectId, + ); + } + + async delete({ id }: { id: string }): Promise { + await this.adapter.remove(id); + } + + async deleteForProject({ projectId }: { projectId: string }): Promise { + const versions = await this.getAllForProject({ projectId }); + await Promise.all( + versions.map((version) => this.adapter.remove(version.id)), + ); + } +} + +export class InMemoryVersionStore implements VersionStore { + private versions = new Map(); + + async put({ version }: { version: ProjectVersion }): Promise { + this.versions.set(version.id, version); + } + + async getAllForProject({ + projectId, + }: { + projectId: string; + }): Promise { + return [...this.versions.values()].filter( + (version) => version.projectId === projectId, + ); + } + + async delete({ id }: { id: string }): Promise { + this.versions.delete(id); + } + + async deleteForProject({ projectId }: { projectId: string }): Promise { + for (const version of this.versions.values()) { + if (version.projectId === projectId) { + this.versions.delete(version.id); + } + } + } +} -- cgit v1.3.1