From 6d913d61101d24105cbe8bd39098f93585addac0 Mon Sep 17 00:00:00 2001 From: Victor Giers Date: Tue, 31 Mar 2026 18:42:36 +0200 Subject: [PATCH] Add project asset storage implementation --- src/assets/project-asset-storage.ts | 251 ++++++++++++++++++++++++++++ 1 file changed, 251 insertions(+) create mode 100644 src/assets/project-asset-storage.ts diff --git a/src/assets/project-asset-storage.ts b/src/assets/project-asset-storage.ts new file mode 100644 index 00000000..cb45bf28 --- /dev/null +++ b/src/assets/project-asset-storage.ts @@ -0,0 +1,251 @@ +export interface ProjectAssetStorageFileRecord { + bytes: ArrayBuffer; + mimeType: string; +} + +export interface ProjectAssetStoragePackageRecord { + files: Record; +} + +export type ProjectAssetStorageRecord = ProjectAssetStoragePackageRecord; +type LegacyProjectAssetStorageRecord = ProjectAssetStorageFileRecord; +type ProjectAssetStorageStoredValue = ProjectAssetStorageRecord | LegacyProjectAssetStorageRecord; + +export interface ProjectAssetStorage { + getAsset(storageKey: string): Promise; + putAsset(storageKey: string, asset: ProjectAssetStorageRecord): Promise; + deleteAsset(storageKey: string): Promise; +} + +export interface ProjectAssetStorageAccessResult { + storage: ProjectAssetStorage | null; + diagnostic: string | null; +} + +const PROJECT_ASSET_DATABASE_NAME = "webeditor3d-project-assets"; +const PROJECT_ASSET_DATABASE_VERSION = 1; +const PROJECT_ASSET_OBJECT_STORE_NAME = "assets"; + +function cloneArrayBuffer(bytes: ArrayBuffer): ArrayBuffer { + return bytes.slice(0); +} + +function cloneFileRecord(file: ProjectAssetStorageFileRecord): ProjectAssetStorageFileRecord { + return { + bytes: cloneArrayBuffer(file.bytes), + mimeType: file.mimeType + }; +} + +export function cloneProjectAssetStorageRecord(record: ProjectAssetStorageRecord): ProjectAssetStorageRecord { + const files: Record = {}; + + for (const [path, file] of Object.entries(record.files)) { + files[path] = cloneFileRecord(file); + } + + return { + files + }; +} + +function isObject(value: unknown): value is Record { + return value !== null && typeof value === "object"; +} + +function isLegacyProjectAssetStorageRecord(value: unknown): value is LegacyProjectAssetStorageRecord { + return ( + isObject(value) && + value.bytes instanceof ArrayBuffer && + typeof value.mimeType === "string" + ); +} + +function isProjectAssetStoragePackageRecord(value: unknown): value is ProjectAssetStorageRecord { + if (!isObject(value) || !isObject(value.files)) { + return false; + } + + return Object.values(value.files).every((entry) => { + return ( + isObject(entry) && + entry.bytes instanceof ArrayBuffer && + typeof entry.mimeType === "string" + ); + }); +} + +function normalizeStoredAssetRecord(storageKey: string, value: unknown): ProjectAssetStorageRecord | null { + if (isProjectAssetStoragePackageRecord(value)) { + return cloneProjectAssetStorageRecord(value); + } + + if (isLegacyProjectAssetStorageRecord(value)) { + return { + files: { + [storageKey]: cloneFileRecord(value) + } + }; + } + + return null; +} + +function getErrorDetail(error: unknown): string { + if (error instanceof Error && error.message.trim().length > 0) { + return error.message.trim(); + } + + return "Unknown error."; +} + +function formatDiagnostic(prefix: string, error: unknown): string { + return `${prefix} ${getErrorDetail(error)}`; +} + +function promisifyRequest(request: IDBRequest): Promise { + return new Promise((resolve, reject) => { + request.addEventListener("success", () => { + resolve(request.result); + }); + request.addEventListener("error", () => { + reject(request.error ?? new Error("IndexedDB request failed.")); + }); + }); +} + +function openIndexedDb(): Promise { + return new Promise((resolve, reject) => { + const request = indexedDB.open(PROJECT_ASSET_DATABASE_NAME, PROJECT_ASSET_DATABASE_VERSION); + + request.addEventListener("upgradeneeded", () => { + const database = request.result; + + if (!database.objectStoreNames.contains(PROJECT_ASSET_OBJECT_STORE_NAME)) { + database.createObjectStore(PROJECT_ASSET_OBJECT_STORE_NAME); + } + }); + + request.addEventListener("success", () => { + resolve(request.result); + }); + + request.addEventListener("error", () => { + reject(request.error ?? new Error("IndexedDB open failed.")); + }); + }); +} + +class IndexedDbProjectAssetStorage implements ProjectAssetStorage { + private readonly databasePromise: Promise; + + constructor(databasePromise: Promise) { + this.databasePromise = databasePromise; + } + + private async withStore(mode: IDBTransactionMode, callback: (store: IDBObjectStore) => IDBRequest): Promise { + const database = await this.databasePromise; + const transaction = database.transaction(PROJECT_ASSET_OBJECT_STORE_NAME, mode); + const store = transaction.objectStore(PROJECT_ASSET_OBJECT_STORE_NAME); + const result = await promisifyRequest(callback(store)); + + await new Promise((resolve, reject) => { + transaction.addEventListener("complete", () => resolve()); + transaction.addEventListener("error", () => reject(transaction.error ?? new Error("IndexedDB transaction failed."))); + transaction.addEventListener("abort", () => reject(transaction.error ?? new Error("IndexedDB transaction aborted."))); + }); + + return result; + } + + async getAsset(storageKey: string): Promise { + const database = await this.databasePromise; + const transaction = database.transaction(PROJECT_ASSET_OBJECT_STORE_NAME, "readonly"); + const store = transaction.objectStore(PROJECT_ASSET_OBJECT_STORE_NAME); + const result = await promisifyRequest(store.get(storageKey)); + + return normalizeStoredAssetRecord(storageKey, result); + } + + async putAsset(storageKey: string, asset: ProjectAssetStorageRecord): Promise { + await this.withStore("readwrite", (store) => + store.put(cloneProjectAssetStorageRecord(asset), storageKey) + ); + } + + async deleteAsset(storageKey: string): Promise { + await this.withStore("readwrite", (store) => store.delete(storageKey)); + } +} + +class InMemoryProjectAssetStorage implements ProjectAssetStorage { + private readonly values = new Map(); + + constructor(initialValues: Record = {}) { + for (const [storageKey, asset] of Object.entries(initialValues)) { + this.values.set(storageKey, cloneStoredAsset(asset)); + } + } + + async getAsset(storageKey: string): Promise { + const asset = this.values.get(storageKey); + + if (asset === undefined) { + return null; + } + + return normalizeStoredAssetRecord(storageKey, asset); + } + + async putAsset(storageKey: string, asset: ProjectAssetStorageRecord): Promise { + this.values.set(storageKey, cloneProjectAssetStorageRecord(asset)); + } + + async deleteAsset(storageKey: string): Promise { + this.values.delete(storageKey); + } +} + +function cloneStoredAsset(asset: ProjectAssetStorageStoredValue): ProjectAssetStorageStoredValue { + if (isLegacyProjectAssetStorageRecord(asset)) { + return cloneFileRecord(asset); + } + + return cloneProjectAssetStorageRecord(asset); +} + +export function createInMemoryProjectAssetStorage( + initialValues: Record = {} +): ProjectAssetStorage { + return new InMemoryProjectAssetStorage(initialValues); +} + +export async function getBrowserProjectAssetStorageAccess(): Promise { + if (typeof window === "undefined") { + return { + storage: null, + diagnostic: null + }; + } + + if (typeof indexedDB === "undefined") { + return { + storage: null, + diagnostic: "IndexedDB is unavailable in this browser environment." + }; + } + + try { + const databasePromise = openIndexedDb(); + await databasePromise; + return { + storage: new IndexedDbProjectAssetStorage(databasePromise), + diagnostic: null + }; + } catch (error) { + return { + storage: null, + diagnostic: formatDiagnostic("Project asset storage could not be opened.", error) + }; + } +}