From ef4364ec6539128af2981abb27d73a0c5c35dc3f Mon Sep 17 00:00:00 2001 From: Victor Giers Date: Fri, 1 May 2026 18:50:27 +0200 Subject: [PATCH] auto-git: [add] src/serialization/editor-draft-storage.ts --- src/serialization/editor-draft-storage.ts | 569 ++++++++++++++++++++++ 1 file changed, 569 insertions(+) create mode 100644 src/serialization/editor-draft-storage.ts diff --git a/src/serialization/editor-draft-storage.ts b/src/serialization/editor-draft-storage.ts new file mode 100644 index 00000000..536a7d6c --- /dev/null +++ b/src/serialization/editor-draft-storage.ts @@ -0,0 +1,569 @@ +import { + createEmptyProjectDocument, + type ProjectDocument +} from "../document/scene-document"; +import { + formatSceneDiagnosticSummary, + validateProjectDocument +} from "../document/scene-document-validation"; +import { + cloneViewportLayoutState, + type ViewportLayoutState +} from "../viewport-three/viewport-layout"; + +import { + DEFAULT_SCENE_DRAFT_STORAGE_KEY, + loadSceneDocumentDraft, + parseViewportLayoutState, + saveSceneDocumentDraft, + type KeyValueStorage, + type LoadOrCreateSceneDocumentResult, + type LoadSceneDocumentDraftResult, + type SaveSceneDocumentDraftResult +} from "./local-draft-storage"; +import { parseProjectDocumentJson } from "./scene-document-json"; + +const EDITOR_DRAFT_DATABASE_NAME = "webeditor3d-editor-drafts"; +const EDITOR_DRAFT_DATABASE_VERSION = 1; +const EDITOR_DRAFT_OBJECT_STORE = "drafts"; +const EDITOR_DOCUMENT_DRAFT_FORMAT = "webeditor3d.editor-document-draft.v1"; +const EDITOR_VIEWPORT_DRAFT_FORMAT = "webeditor3d.editor-viewport-draft.v1"; + +export type EditorDraftSaveResult = SaveSceneDocumentDraftResult; +export type EditorDraftLoadResult = LoadSceneDocumentDraftResult; + +export interface EditorDraftStorage { + saveDocumentDraft( + document: ProjectDocument, + fallbackViewportLayoutState?: ViewportLayoutState | null + ): Promise; + saveViewportLayoutDraft( + viewportLayoutState: ViewportLayoutState, + fallbackDocument?: ProjectDocument + ): Promise; + loadDraft(): Promise; + flushEmergencyFallback?( + document: ProjectDocument, + viewportLayoutState: ViewportLayoutState | null + ): EditorDraftSaveResult; +} + +export interface BrowserEditorDraftStorageAccessResult { + storage: EditorDraftStorage | null; + diagnostic: string | null; +} + +interface EditorDocumentDraftRecord { + format: typeof EDITOR_DOCUMENT_DRAFT_FORMAT; + savedAt: number; + document: ProjectDocument; +} + +interface EditorViewportDraftRecord { + format: typeof EDITOR_VIEWPORT_DRAFT_FORMAT; + savedAt: number; + viewportLayoutState: ViewportLayoutState; +} + +interface IndexedDbRequestLike { + result: T; + error: DOMException | null; + onsuccess: ((event: Event) => void) | null; + onerror: ((event: Event) => void) | null; +} + +function getErrorDetail(error: unknown): string { + if (error instanceof Error && error.message.trim().length > 0) { + return error.message.trim(); + } + + return "Unknown error."; +} + +function createSavedResult(message = "Autosave updated."): EditorDraftSaveResult { + return { + status: "saved", + message + }; +} + +function createSkippedResult(message: string): EditorDraftSaveResult { + return { + status: "skipped", + message + }; +} + +function createErrorResult(prefix: string, error: unknown): EditorDraftSaveResult { + return { + status: "error", + message: `${prefix} ${getErrorDetail(error)}` + }; +} + +function assertProjectDocumentDraftIsValid(document: ProjectDocument) { + const validation = validateProjectDocument(document, { + terrainSampleValues: "skip" + }); + + if (validation.errors.length > 0) { + throw new Error( + `Project document draft has ${validation.errors.length} validation error(s): ${formatSceneDiagnosticSummary(validation.errors)}` + ); + } +} + +function parseProjectDocumentDraft(value: unknown): ProjectDocument { + return parseProjectDocumentJson(JSON.stringify(value)); +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null; +} + +function isEditorDocumentDraftRecord( + value: unknown +): value is EditorDocumentDraftRecord { + return isRecord(value) && value.format === EDITOR_DOCUMENT_DRAFT_FORMAT; +} + +function isEditorViewportDraftRecord( + value: unknown +): value is EditorViewportDraftRecord { + return isRecord(value) && value.format === EDITOR_VIEWPORT_DRAFT_FORMAT; +} + +function getDocumentRecordKey(draftKey: string): string { + return `${draftKey}:document`; +} + +function getViewportRecordKey(draftKey: string): string { + return `${draftKey}:viewport`; +} + +function requestToPromise(request: IDBRequest): Promise { + return new Promise((resolve, reject) => { + request.onsuccess = () => resolve(request.result); + request.onerror = () => reject(request.error ?? new Error("IndexedDB request failed.")); + }); +} + +function transactionDone(transaction: IDBTransaction): Promise { + return new Promise((resolve, reject) => { + transaction.oncomplete = () => resolve(); + transaction.onerror = () => + reject(transaction.error ?? new Error("IndexedDB transaction failed.")); + transaction.onabort = () => + reject(transaction.error ?? new Error("IndexedDB transaction aborted.")); + }); +} + +function openEditorDraftDatabase( + indexedDb: IDBFactory = indexedDB +): Promise { + return new Promise((resolve, reject) => { + const request = indexedDb.open( + EDITOR_DRAFT_DATABASE_NAME, + EDITOR_DRAFT_DATABASE_VERSION + ); + + request.onupgradeneeded = () => { + const database = request.result; + + if (!database.objectStoreNames.contains(EDITOR_DRAFT_OBJECT_STORE)) { + database.createObjectStore(EDITOR_DRAFT_OBJECT_STORE); + } + }; + request.onsuccess = () => resolve(request.result); + request.onerror = () => + reject(request.error ?? new Error("IndexedDB database open failed.")); + request.onblocked = () => + reject(new Error("IndexedDB database upgrade was blocked.")); + }); +} + +export class IndexedDbEditorDraftStorage implements EditorDraftStorage { + constructor( + private readonly database: IDBDatabase, + private readonly draftKey = DEFAULT_SCENE_DRAFT_STORAGE_KEY, + private readonly legacyStorage: KeyValueStorage | null = null + ) {} + + async saveDocumentDraft( + document: ProjectDocument, + _fallbackViewportLayoutState: ViewportLayoutState | null = null + ): Promise { + try { + assertProjectDocumentDraftIsValid(document); + await this.putRecord(getDocumentRecordKey(this.draftKey), { + format: EDITOR_DOCUMENT_DRAFT_FORMAT, + savedAt: Date.now(), + document + } satisfies EditorDocumentDraftRecord); + return createSavedResult(); + } catch (error) { + return createErrorResult("Autosave could not be saved.", error); + } + } + + async saveViewportLayoutDraft( + viewportLayoutState: ViewportLayoutState + ): Promise { + try { + await this.putRecord(getViewportRecordKey(this.draftKey), { + format: EDITOR_VIEWPORT_DRAFT_FORMAT, + savedAt: Date.now(), + viewportLayoutState: cloneViewportLayoutState(viewportLayoutState) + } satisfies EditorViewportDraftRecord); + return createSavedResult(); + } catch (error) { + return createErrorResult("Autosave could not be saved.", error); + } + } + + async loadDraft(): Promise { + try { + const documentRecord = await this.getRecord( + getDocumentRecordKey(this.draftKey) + ); + + if (documentRecord === undefined) { + return this.loadLegacyDraft(); + } + + if (!isEditorDocumentDraftRecord(documentRecord)) { + throw new Error("IndexedDB document draft has an unsupported format."); + } + + const viewportRecord = await this.getRecord( + getViewportRecordKey(this.draftKey) + ); + let viewportLayoutState: ViewportLayoutState | null = null; + + if (viewportRecord !== undefined) { + if (!isEditorViewportDraftRecord(viewportRecord)) { + throw new Error("IndexedDB viewport draft has an unsupported format."); + } + + viewportLayoutState = parseViewportLayoutState( + viewportRecord.viewportLayoutState + ); + } + + return { + status: "loaded", + document: parseProjectDocumentDraft(documentRecord.document), + viewportLayoutState, + message: "Recovered latest autosave." + }; + } catch (error) { + return { + status: "error", + message: `Stored autosave could not be loaded. ${getErrorDetail(error)}` + }; + } + } + + flushEmergencyFallback( + document: ProjectDocument, + viewportLayoutState: ViewportLayoutState | null + ): EditorDraftSaveResult { + if (this.legacyStorage === null) { + return createSkippedResult( + "Emergency localStorage autosave skipped because browser local storage is unavailable." + ); + } + + return saveSceneDocumentDraft( + this.legacyStorage, + document, + viewportLayoutState, + this.draftKey + ); + } + + private async getRecord(key: string): Promise { + const transaction = this.database.transaction( + EDITOR_DRAFT_OBJECT_STORE, + "readonly" + ); + const store = transaction.objectStore(EDITOR_DRAFT_OBJECT_STORE); + const result = await requestToPromise(store.get(key)); + await transactionDone(transaction); + return result; + } + + private async putRecord(key: string, value: unknown): Promise { + const transaction = this.database.transaction( + EDITOR_DRAFT_OBJECT_STORE, + "readwrite" + ); + const store = transaction.objectStore(EDITOR_DRAFT_OBJECT_STORE); + + store.put(value, key); + await transactionDone(transaction); + } + + private loadLegacyDraft(): EditorDraftLoadResult { + if (this.legacyStorage === null) { + return { + status: "missing", + message: "No autosave was found." + }; + } + + return loadSceneDocumentDraft(this.legacyStorage, this.draftKey); + } +} + +export class LocalStorageEditorDraftStorage implements EditorDraftStorage { + private latestDocument: ProjectDocument | null = null; + + constructor( + private readonly storage: KeyValueStorage, + private readonly draftKey = DEFAULT_SCENE_DRAFT_STORAGE_KEY + ) {} + + async saveDocumentDraft( + document: ProjectDocument, + fallbackViewportLayoutState: ViewportLayoutState | null = null + ): Promise { + this.latestDocument = document; + return saveSceneDocumentDraft( + this.storage, + document, + fallbackViewportLayoutState, + this.draftKey + ); + } + + async saveViewportLayoutDraft( + viewportLayoutState: ViewportLayoutState, + fallbackDocument?: ProjectDocument + ): Promise { + const document = fallbackDocument ?? this.latestDocument; + + if (document === null) { + return createSkippedResult( + "Viewport autosave skipped because no document draft is available for localStorage fallback." + ); + } + + return saveSceneDocumentDraft( + this.storage, + document, + viewportLayoutState, + this.draftKey + ); + } + + async loadDraft(): Promise { + const result = loadSceneDocumentDraft(this.storage, this.draftKey); + + if (result.status === "loaded") { + this.latestDocument = result.document; + } + + return result; + } + + flushEmergencyFallback( + document: ProjectDocument, + viewportLayoutState: ViewportLayoutState | null + ): EditorDraftSaveResult { + this.latestDocument = document; + return saveSceneDocumentDraft( + this.storage, + document, + viewportLayoutState, + this.draftKey + ); + } +} + +export class MemoryEditorDraftStorage implements EditorDraftStorage { + private documentRecord: EditorDocumentDraftRecord | null = null; + private viewportRecord: EditorViewportDraftRecord | null = null; + + constructor( + private readonly options: { + legacyStorage?: KeyValueStorage | null; + draftKey?: string; + } = {} + ) {} + + async saveDocumentDraft( + document: ProjectDocument + ): Promise { + try { + assertProjectDocumentDraftIsValid(document); + this.documentRecord = { + format: EDITOR_DOCUMENT_DRAFT_FORMAT, + savedAt: Date.now(), + document + }; + return createSavedResult(); + } catch (error) { + return createErrorResult("Autosave could not be saved.", error); + } + } + + async saveViewportLayoutDraft( + viewportLayoutState: ViewportLayoutState + ): Promise { + this.viewportRecord = { + format: EDITOR_VIEWPORT_DRAFT_FORMAT, + savedAt: Date.now(), + viewportLayoutState: cloneViewportLayoutState(viewportLayoutState) + }; + return createSavedResult(); + } + + async loadDraft(): Promise { + if (this.documentRecord === null) { + const legacyStorage = this.options.legacyStorage ?? null; + + if (legacyStorage !== null) { + return loadSceneDocumentDraft( + legacyStorage, + this.options.draftKey ?? DEFAULT_SCENE_DRAFT_STORAGE_KEY + ); + } + + return { + status: "missing", + message: "No autosave was found." + }; + } + + return { + status: "loaded", + document: parseProjectDocumentDraft(this.documentRecord.document), + viewportLayoutState: + this.viewportRecord === null + ? null + : parseViewportLayoutState(this.viewportRecord.viewportLayoutState), + message: "Recovered latest autosave." + }; + } + + flushEmergencyFallback( + document: ProjectDocument, + viewportLayoutState: ViewportLayoutState | null + ): EditorDraftSaveResult { + const legacyStorage = this.options.legacyStorage ?? null; + + if (legacyStorage === null) { + return createSkippedResult( + "Emergency localStorage autosave skipped because browser local storage is unavailable." + ); + } + + return saveSceneDocumentDraft( + legacyStorage, + document, + viewportLayoutState, + this.options.draftKey ?? DEFAULT_SCENE_DRAFT_STORAGE_KEY + ); + } +} + +export async function createBrowserEditorDraftStorage(options: { + legacyStorage: KeyValueStorage | null; + draftKey?: string; + indexedDb?: IDBFactory | null; +}): Promise { + const draftKey = options.draftKey ?? DEFAULT_SCENE_DRAFT_STORAGE_KEY; + const indexedDb = + options.indexedDb === undefined + ? typeof indexedDB === "undefined" + ? null + : indexedDB + : options.indexedDb; + + if (indexedDb !== null) { + try { + return { + storage: new IndexedDbEditorDraftStorage( + await openEditorDraftDatabase(indexedDb), + draftKey, + options.legacyStorage + ), + diagnostic: null + }; + } catch (error) { + if (options.legacyStorage !== null) { + return { + storage: new LocalStorageEditorDraftStorage( + options.legacyStorage, + draftKey + ), + diagnostic: `IndexedDB autosave is unavailable; using localStorage fallback. ${getErrorDetail(error)}` + }; + } + + return { + storage: null, + diagnostic: `IndexedDB autosave is unavailable and localStorage fallback is unavailable. ${getErrorDetail(error)}` + }; + } + } + + if (options.legacyStorage !== null) { + return { + storage: new LocalStorageEditorDraftStorage(options.legacyStorage, draftKey), + diagnostic: "IndexedDB autosave is unavailable; using localStorage fallback." + }; + } + + return { + storage: null, + diagnostic: null + }; +} + +export async function loadOrCreateEditorDraft( + storage: EditorDraftStorage | null +): Promise { + if (storage === null) { + return { + document: createEmptyProjectDocument(), + viewportLayoutState: null, + diagnostic: null + }; + } + + const draftResult = await storage.loadDraft(); + + switch (draftResult.status) { + case "loaded": + return { + document: draftResult.document, + viewportLayoutState: draftResult.viewportLayoutState, + diagnostic: draftResult.message + }; + case "missing": + return { + document: createEmptyProjectDocument(), + viewportLayoutState: null, + diagnostic: null + }; + case "error": + return { + document: createEmptyProjectDocument(), + viewportLayoutState: null, + diagnostic: `${draftResult.message} Starting with a fresh empty document.` + }; + } +} + +export function createResolvedIndexedDbRequest( + result: T +): IndexedDbRequestLike { + return { + result, + error: null, + onsuccess: null, + onerror: null + }; +}