diff --git a/services/db/core/backupStorage.ts b/services/db/core/backupStorage.ts new file mode 100644 index 0000000..909ea43 --- /dev/null +++ b/services/db/core/backupStorage.ts @@ -0,0 +1,237 @@ +/** + * Backup Storage Service + * + * Tiered storage abstraction for migration backups. + * Tries storage in order of preference: OPFS → Backup DB → localStorage → User Download + */ + +import { + BACKUP_METADATA_KEY, + BACKUP_DATA_KEY, + BACKUP_DB_NAME, + type MigrationBackupMetadata, +} from './migrationTypes'; + +/** + * Try to store backup using tiered storage strategy + */ +export async function storeBackup( + backupJson: string, + metadata: MigrationBackupMetadata +): Promise { + // Tier 1: OPFS (Origin Private File System) - best for large data + if (await tryStoreInOPFS(backupJson, metadata)) { + metadata.storage = 'opfs'; + localStorage.setItem(BACKUP_METADATA_KEY, JSON.stringify(metadata)); + return true; + } + + // Tier 2: Separate backup IndexedDB (never migrated) + if (await tryStoreInBackupDb(backupJson, metadata)) { + metadata.storage = 'backupDb'; + localStorage.setItem(BACKUP_METADATA_KEY, JSON.stringify(metadata)); + return true; + } + + // Tier 3: localStorage (size limited but always available) + if (await tryStoreInLocalStorage(backupJson, metadata)) { + metadata.storage = 'localStorage'; + localStorage.setItem(BACKUP_METADATA_KEY, JSON.stringify(metadata)); + return true; + } + + // Tier 4: Prompt user to download file + if (await promptUserDownload(backupJson, metadata)) { + metadata.storage = 'userDownload'; + localStorage.setItem(BACKUP_METADATA_KEY, JSON.stringify(metadata)); + return true; + } + + return false; +} + +/** + * Store backup in Origin Private File System + */ +async function tryStoreInOPFS( + backupJson: string, + metadata: MigrationBackupMetadata +): Promise { + try { + if (!('storage' in navigator) || !('getDirectory' in (navigator.storage || {}))) { + console.log('[BackupStorage] OPFS not available'); + return false; + } + + const root = await navigator.storage.getDirectory(); + const backupsDir = await root.getDirectoryHandle('migration-backups', { create: true }); + const fileName = `backup-v${metadata.fromVersion}-${Date.now()}.json`; + const fileHandle = await backupsDir.getFileHandle(fileName, { create: true }); + + const writable = await fileHandle.createWritable(); + await writable.write(backupJson); + await writable.close(); + + metadata.fileName = fileName; + console.log(`[BackupStorage] Stored in OPFS: ${fileName}`); + return true; + } catch (e) { + console.warn('[BackupStorage] OPFS storage failed:', e); + return false; + } +} + +/** + * Store backup in a separate IndexedDB (never migrated) + */ +async function tryStoreInBackupDb( + backupJson: string, + metadata: MigrationBackupMetadata +): Promise { + try { + const backupDb = await new Promise((resolve, reject) => { + const request = indexedDB.open(BACKUP_DB_NAME, 1); + request.onupgradeneeded = () => { + const db = request.result; + if (!db.objectStoreNames.contains('backups')) { + db.createObjectStore('backups', { keyPath: 'id' }); + } + }; + request.onsuccess = () => resolve(request.result); + request.onerror = () => reject(request.error); + }); + + const tx = backupDb.transaction('backups', 'readwrite'); + const store = tx.objectStore('backups'); + + const backupId = `v${metadata.fromVersion}-${metadata.timestamp}`; + + await new Promise((resolve, reject) => { + const request = store.put({ + id: backupId, + metadata, + data: backupJson, + createdAt: new Date().toISOString(), + }); + request.onsuccess = () => resolve(); + request.onerror = () => reject(request.error); + }); + + backupDb.close(); + console.log(`[BackupStorage] Stored in backup database: ${backupId}`); + return true; + } catch (e) { + console.warn('[BackupStorage] Backup DB storage failed:', e); + return false; + } +} + +/** + * Store backup in localStorage (size limited) + */ +async function tryStoreInLocalStorage( + backupJson: string, + metadata: MigrationBackupMetadata +): Promise { + try { + // localStorage limit is typically 5-10MB + const MAX_LOCALSTORAGE_SIZE = 4 * 1024 * 1024; // 4MB to be safe + + if (metadata.sizeBytes > MAX_LOCALSTORAGE_SIZE) { + console.warn(`[BackupStorage] Data too large for localStorage: ${(metadata.sizeBytes / 1024 / 1024).toFixed(2)}MB`); + return false; + } + + localStorage.setItem(BACKUP_DATA_KEY, backupJson); + console.log('[BackupStorage] Stored in localStorage'); + return true; + } catch (e) { + console.warn('[BackupStorage] localStorage storage failed:', e); + return false; + } +} + +/** + * Prompt user to download backup file (last resort) + */ +async function promptUserDownload( + backupJson: string, + metadata: MigrationBackupMetadata +): Promise { + return new Promise((resolve) => { + const fileName = `lexiconforge-backup-v${metadata.fromVersion}-${metadata.timestamp.replace(/[:.]/g, '-')}.json`; + + const confirmed = window.confirm( + `LexiconForge needs to upgrade your data (v${metadata.fromVersion} → v${metadata.toVersion}).\n\n` + + `For safety, please save a backup file before continuing.\n\n` + + `Click OK to download the backup.` + ); + + if (confirmed) { + try { + const blob = new Blob([backupJson], { type: 'application/json' }); + const url = URL.createObjectURL(blob); + + const a = document.createElement('a'); + a.href = url; + a.download = fileName; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + URL.revokeObjectURL(url); + + metadata.fileName = fileName; + console.log(`[BackupStorage] User downloaded backup: ${fileName}`); + resolve(true); + } catch (e) { + console.error('[BackupStorage] Download failed:', e); + resolve(false); + } + } else { + console.warn('[BackupStorage] User declined to download backup'); + resolve(false); + } + }); +} + +/** + * Clean up backup from a specific storage tier + */ +export async function cleanupStorageTier(metadata: MigrationBackupMetadata): Promise { + try { + switch (metadata.storage) { + case 'opfs': + if (metadata.fileName) { + const root = await navigator.storage.getDirectory(); + const backupsDir = await root.getDirectoryHandle('migration-backups'); + await backupsDir.removeEntry(metadata.fileName); + } + break; + + case 'backupDb': + const db = await new Promise((resolve, reject) => { + const request = indexedDB.open(BACKUP_DB_NAME, 1); + request.onsuccess = () => resolve(request.result); + request.onerror = () => reject(request.error); + }); + const tx = db.transaction('backups', 'readwrite'); + const store = tx.objectStore('backups'); + const backupId = `v${metadata.fromVersion}-${metadata.timestamp}`; + store.delete(backupId); + db.close(); + break; + + case 'localStorage': + localStorage.removeItem(BACKUP_DATA_KEY); + break; + + case 'userDownload': + // Nothing to clean up - user has the file + break; + } + + console.log('[BackupStorage] Storage tier cleaned up'); + } catch (e) { + console.warn('[BackupStorage] Cleanup failed:', e); + } +} diff --git a/services/db/core/migrationTypes.ts b/services/db/core/migrationTypes.ts new file mode 100644 index 0000000..fd717a4 --- /dev/null +++ b/services/db/core/migrationTypes.ts @@ -0,0 +1,108 @@ +/** + * Migration Types + * + * Shared types, interfaces, and constants for the migration safety system. + */ + +// Storage keys +export const BACKUP_METADATA_KEY = 'lexiconforge-migration-backup-metadata'; +export const BACKUP_DATA_KEY = 'lexiconforge-migration-backup-data'; +export const BACKUP_DB_NAME = 'lexiconforge-backups'; + +export type BackupStorageTier = 'opfs' | 'backupDb' | 'localStorage' | 'userDownload'; + +export interface MigrationBackupMetadata { + fromVersion: number; + toVersion: number; + timestamp: string; + chapterCount: number; + translationCount: number; + sizeBytes: number; + status: 'pending' | 'completed' | 'failed'; + storage: BackupStorageTier; + fileName?: string; +} + +export interface BackupData { + metadata: MigrationBackupMetadata; + chapters: any[]; + translations: any[]; + settings: any[]; + feedback: any[]; + promptTemplates: any[]; + urlMappings: any[]; + novels: any[]; + chapterSummaries: any[]; + amendmentLogs: any[]; + diffResults: any[]; +} + +export interface RestoreResult { + success: boolean; + message: string; + restoredVersion?: number; + recordsRestored?: { + chapters: number; + translations: number; + settings: number; + feedback: number; + other: number; + }; +} + +/** + * Check if a backup is needed before migration + */ +export function needsPreMigrationBackup(oldVersion: number, newVersion: number): boolean { + // Only backup if upgrading from an existing DB (not fresh install) + return oldVersion > 0 && oldVersion < newVersion; +} + +/** + * Get stored backup metadata if exists + */ +export function getBackupMetadata(): MigrationBackupMetadata | null { + try { + const metadataStr = localStorage.getItem(BACKUP_METADATA_KEY); + if (!metadataStr) return null; + return JSON.parse(metadataStr); + } catch { + return null; + } +} + +/** + * Save backup metadata + */ +export function setBackupMetadata(metadata: MigrationBackupMetadata): void { + localStorage.setItem(BACKUP_METADATA_KEY, JSON.stringify(metadata)); +} + +/** + * Clear backup metadata + */ +export function clearBackupMetadata(): void { + localStorage.removeItem(BACKUP_METADATA_KEY); +} + +/** + * Mark backup as completed (called after successful migration) + */ +export function markBackupCompleted(): void { + const metadata = getBackupMetadata(); + if (metadata) { + metadata.status = 'completed'; + setBackupMetadata(metadata); + } +} + +/** + * Mark backup as failed (called if migration fails) + */ +export function markBackupFailed(): void { + const metadata = getBackupMetadata(); + if (metadata) { + metadata.status = 'failed'; + setBackupMetadata(metadata); + } +} diff --git a/services/db/core/restoreStorage.ts b/services/db/core/restoreStorage.ts new file mode 100644 index 0000000..b3259f6 --- /dev/null +++ b/services/db/core/restoreStorage.ts @@ -0,0 +1,170 @@ +/** + * Restore Storage Service + * + * Tiered retrieval abstraction for migration backups. + * Retrieves backup data from the storage tier specified in metadata. + */ + +import { + BACKUP_DATA_KEY, + BACKUP_DB_NAME, + type MigrationBackupMetadata, + type BackupData, +} from './migrationTypes'; + +/** + * Retrieve backup data from the appropriate storage + */ +export async function retrieveBackupData( + metadata: MigrationBackupMetadata +): Promise { + try { + let backupJson: string | null = null; + + switch (metadata.storage) { + case 'opfs': + backupJson = await retrieveFromOPFS(metadata); + break; + + case 'backupDb': + backupJson = await retrieveFromBackupDb(metadata); + break; + + case 'localStorage': + backupJson = localStorage.getItem(BACKUP_DATA_KEY); + break; + + case 'userDownload': + backupJson = await promptUserUpload(); + break; + + default: + console.error(`[RestoreStorage] Unknown storage type: ${metadata.storage}`); + return null; + } + + if (!backupJson) { + return null; + } + + return JSON.parse(backupJson) as BackupData; + } catch (error) { + console.error('[RestoreStorage] Failed to retrieve backup data:', error); + return null; + } +} + +/** + * Retrieve from OPFS + */ +async function retrieveFromOPFS( + metadata: MigrationBackupMetadata +): Promise { + if (!metadata.fileName) { + return null; + } + + try { + const root = await navigator.storage.getDirectory(); + const backupsDir = await root.getDirectoryHandle('migration-backups'); + const fileHandle = await backupsDir.getFileHandle(metadata.fileName); + const file = await fileHandle.getFile(); + return await file.text(); + } catch (e) { + console.error('[RestoreStorage] OPFS retrieval failed:', e); + return null; + } +} + +/** + * Retrieve from backup database + */ +async function retrieveFromBackupDb( + metadata: MigrationBackupMetadata +): Promise { + try { + const db = await new Promise((resolve, reject) => { + const request = indexedDB.open(BACKUP_DB_NAME, 1); + request.onsuccess = () => resolve(request.result); + request.onerror = () => reject(request.error); + }); + + const tx = db.transaction('backups', 'readonly'); + const store = tx.objectStore('backups'); + const backupId = `v${metadata.fromVersion}-${metadata.timestamp}`; + + const record = await new Promise((resolve, reject) => { + const request = store.get(backupId); + request.onsuccess = () => resolve(request.result); + request.onerror = () => reject(request.error); + }); + + db.close(); + + return record?.data || null; + } catch (e) { + console.error('[RestoreStorage] Backup DB retrieval failed:', e); + return null; + } +} + +/** + * Prompt user to upload their backup file + */ +async function promptUserUpload(): Promise { + return new Promise((resolve) => { + const message = + 'Please select your backup file to restore your data.\n\n' + + 'The file should be named like: lexiconforge-backup-v*.json'; + + if (!window.confirm(message)) { + resolve(null); + return; + } + + const input = document.createElement('input'); + input.type = 'file'; + input.accept = '.json,application/json'; + + input.onchange = async (e) => { + const file = (e.target as HTMLInputElement).files?.[0]; + if (file) { + try { + const text = await file.text(); + // Validate it's a backup file + const parsed = JSON.parse(text); + if (parsed.metadata && parsed.chapters) { + resolve(text); + } else { + alert('Invalid backup file format'); + resolve(null); + } + } catch (err) { + alert('Failed to read backup file'); + resolve(null); + } + } else { + resolve(null); + } + }; + + input.oncancel = () => resolve(null); + + input.click(); + }); +} + +/** + * Parse and validate backup JSON + */ +export function parseBackupJson(backupJson: string): BackupData | null { + try { + const data = JSON.parse(backupJson) as BackupData; + if (!data.metadata || !Array.isArray(data.chapters)) { + return null; + } + return data; + } catch { + return null; + } +}