import type { UserExportPreset } from './preset.types'; import { parseUserPresetJson, serializeUserPreset, validateUserPreset, } from './preset-validation'; export interface UserPresetPersistence { load(): Promise; save(presets: readonly UserExportPreset[]): Promise; } export class UserPresetRegistry { readonly #persistence: UserPresetPersistence; #presets = new Map(); constructor(persistence: UserPresetPersistence) { this.#persistence = persistence; } async initialize(): Promise { const loaded = await this.#persistence.load(); const validated = loaded.map((preset) => { const result = validateUserPreset(preset); if (!result.valid || !result.preset) { throw new TypeError( `Stored preset is invalid: ${result.errors.join('; ')}` ); } return result.preset; }); this.#presets = new Map(validated.map((preset) => [preset.id, preset])); } list(): readonly UserExportPreset[] { return Object.freeze( [...this.#presets.values()].sort((left, right) => left.name.localeCompare(right.name) ) ); } get(id: string): UserExportPreset | undefined { return this.#presets.get(id); } async save(preset: UserExportPreset): Promise { const result = validateUserPreset(preset); if (!result.valid || !result.preset) { throw new TypeError(`Invalid preset: ${result.errors.join('; ')}`); } this.#presets.set(result.preset.id, result.preset); await this.#persist(); } async clone( id: string, newId: string, newName: string ): Promise { const original = this.#presets.get(id); if (!original) { throw new RangeError(`Unknown preset: ${id}`); } const cloned = { ...original, id: newId, name: newName }; await this.save(cloned); return this.#presets.get(newId) as UserExportPreset; } async rename(id: string, name: string): Promise { const preset = this.#presets.get(id); if (!preset) { throw new RangeError(`Unknown preset: ${id}`); } await this.save({ ...preset, name }); } async delete(id: string): Promise { const deleted = this.#presets.delete(id); if (deleted) { await this.#persist(); } return deleted; } async reset(): Promise { this.#presets.clear(); await this.#persist(); } export(id: string): string { const preset = this.#presets.get(id); if (!preset) { throw new RangeError(`Unknown preset: ${id}`); } return serializeUserPreset(preset); } async import(json: string): Promise { const preset = parseUserPresetJson(json); await this.save(preset); return preset; } async #persist(): Promise { await this.#persistence.save(this.list()); } } export class MemoryUserPresetPersistence implements UserPresetPersistence { #presets: readonly UserExportPreset[] = []; async load(): Promise { return this.#presets; } async save(presets: readonly UserExportPreset[]): Promise { this.#presets = structuredClone(presets); } } export interface IndexedDbUserPresetPersistenceOptions { readonly databaseName?: string; readonly storeName?: string; readonly indexedDB?: IDBFactory; } /** * Stores only validated typed preset documents. A dedicated database avoids * version races with durable job metadata stores that may be opened first. */ export class IndexedDbUserPresetPersistence implements UserPresetPersistence { readonly #databaseName: string; readonly #storeName: string; readonly #factory: IDBFactory; #database?: Promise; constructor(options: IndexedDbUserPresetPersistenceOptions = {}) { const factory = options.indexedDB ?? globalThis.indexedDB; if (!factory) { throw new Error('IndexedDB is unavailable'); } this.#factory = factory; this.#databaseName = options.databaseName ?? 'av-tools-user-presets-v1'; this.#storeName = options.storeName ?? 'presets'; } async load(): Promise { const request = (await this.#store('readonly')).get('all'); const value = await requestResult(request); if (value === undefined) { return Object.freeze([]); } if (!Array.isArray(value)) { throw new TypeError('Stored user presets are invalid'); } return Object.freeze( value.map((preset) => { const result = validateUserPreset(preset); if (!result.valid || !result.preset) { throw new TypeError( `Stored preset is invalid: ${result.errors.join('; ')}` ); } return result.preset; }) ); } async save(presets: readonly UserExportPreset[]): Promise { const validated = presets.map((preset) => { const result = validateUserPreset(preset); if (!result.valid || !result.preset) { throw new TypeError(`Invalid preset: ${result.errors.join('; ')}`); } return result.preset; }); const store = await this.#store('readwrite'); const completion = transactionComplete(store.transaction); await requestResult( store.put(structuredClone(validated), 'all') ); await completion; } async #store(mode: IDBTransactionMode): Promise { const database = await (this.#database ??= this.#open()); return database .transaction(this.#storeName, mode) .objectStore(this.#storeName); } #open(): Promise { return new Promise((resolve, reject) => { const request = this.#factory.open(this.#databaseName, 1); request.onupgradeneeded = () => { if (!request.result.objectStoreNames.contains(this.#storeName)) { request.result.createObjectStore(this.#storeName); } }; request.onsuccess = () => resolve(request.result); request.onerror = () => reject(request.error ?? new Error('Could not open preset storage')); request.onblocked = () => reject(new Error('Preset storage upgrade is blocked')); }); } } function requestResult(request: IDBRequest): Promise { return new Promise((resolve, reject) => { request.onsuccess = () => resolve(request.result); request.onerror = () => reject(request.error ?? new Error('Preset storage request failed')); }); } function transactionComplete(transaction: IDBTransaction): Promise { return new Promise((resolve, reject) => { transaction.oncomplete = () => resolve(); transaction.onerror = () => reject( transaction.error ?? new Error('Preset storage transaction failed') ); transaction.onabort = () => reject(transaction.error ?? new Error('Preset storage was aborted')); }); }