diff --git a/docs/WORKLOG.md b/docs/WORKLOG.md index 6f5bbfa..67677d0 100644 --- a/docs/WORKLOG.md +++ b/docs/WORKLOG.md @@ -1165,3 +1165,13 @@ Next: After running with reduced logs, gather traces for 'Chapter not found' and - Files: services/epubService.ts; services/epubService/**; services/epub/types.ts; docs/WORKLOG.md - Why: Decompose the `services/epubService.ts` monolith without breaking the existing `services/epub/*` pipeline/types; keep new modules <300 LOC. - Tests: `npx tsc --noEmit`; `npm test -- --run tests/services/epubService.test.ts tests/epub/*.test.ts` + +2025-12-22 08:42 UTC - DB safety: pre-migration backup + version gate + restore +- Files: services/db/core/connection.ts; services/db/core/errors.ts; services/db/core/migrationBackup.ts; services/db/core/migrationRestore.ts; services/db/core/versionGate.ts; tests/db/migrations/{migrationBackup.test.ts,migrationRestore.test.ts,versionGate.test.ts}; docs/WORKLOG.md +- Why: Prevent data loss during schema upgrades, and fail loudly when the DB version is newer than the app (so we don’t silently corrupt/overwrite). +- Details: + - services/db/core/connection.ts#L76: add `prepareConnection()` (version check + backup) and ensure `getConnection()` sets `_connectionPromise` before awaits to avoid stampede opens. + - services/db/core/migrationBackup.ts#L31: export-and-store backup before migrations using tiered storage (OPFS → backup DB → localStorage → user download). + - services/db/core/versionGate.ts#L36: version peek without triggering upgrades; returns actionable status for newer/older/corrupted DB + failed migration marker. + - services/db/core/migrationRestore.ts#L51: restore flow that deletes the DB, recreates at `fromVersion`, replays migrations, then imports backup. +- Tests: `npx tsc --noEmit`; `npm test -- --run tests/db/open-singleton.test.ts tests/db/migrations/*` diff --git a/services/db/core/connection.ts b/services/db/core/connection.ts index 6686455..f135cf2 100644 --- a/services/db/core/connection.ts +++ b/services/db/core/connection.ts @@ -1,12 +1,29 @@ /** * Database Connection Management - Hybrid Approach - * + * * Single source of truth for IndexedDB connection with upgrade hooks. * Handles browser compatibility and provides fallback detection. + * + * Safety features: + * - Pre-migration backup before schema upgrades + * - Version gate to prevent opening newer DB with older app + * - Restore capability if migration fails */ import { DbError, mapDomError } from './errors'; import { applyMigrations, SCHEMA_VERSIONS, STORE_NAMES } from './schema'; +import { + createPreMigrationBackup, + markBackupCompleted, + markBackupFailed, + cleanupOldBackups, +} from './migrationBackup'; +import { + checkDatabaseVersion, + formatVersionCheck, + shouldBlockApp, + type VersionCheckResult, +} from './versionGate'; // Database configuration constants export const DB_NAME = 'lexicon-forge'; @@ -34,6 +51,7 @@ export function isIndexedDBAvailable(): boolean { // Connection singleton let _dbConnection: IDBDatabase | null = null; let _connectionPromise: Promise | null = null; +let _versionCheckResult: VersionCheckResult | null = null; export function resetConnection(): void { if (_dbConnection) { @@ -45,10 +63,52 @@ export function resetConnection(): void { } _dbConnection = null; _connectionPromise = null; + _versionCheckResult = null; } /** - * Get or create the database connection + * Get the last version check result (useful for UI) + */ +export function getLastVersionCheck(): VersionCheckResult | null { + return _versionCheckResult; +} + +/** + * Perform version check and backup if needed, BEFORE opening connection. + * Returns the version check result so the app can decide how to proceed. + */ +export async function prepareConnection(): Promise { + // Run version check + _versionCheckResult = await checkDatabaseVersion(DB_NAME, DB_VERSION); + console.log(`[Connection] Version check: ${formatVersionCheck(_versionCheckResult)}`); + + // If we can't proceed, return early and let caller handle it + if (shouldBlockApp(_versionCheckResult)) { + return _versionCheckResult; + } + + // If upgrade is needed, create backup first + if (_versionCheckResult.requiresBackup && _versionCheckResult.currentDbVersion !== null) { + console.log('[Connection] Creating pre-migration backup...'); + + const backupSuccess = await createPreMigrationBackup( + DB_NAME, + _versionCheckResult.currentDbVersion, + DB_VERSION + ); + + if (!backupSuccess) { + console.warn('[Connection] Backup failed - proceeding anyway (user was warned)'); + // We don't block here - backup service already prompted user + } + } + + return _versionCheckResult; +} + +/** + * Get or create the database connection. + * Call prepareConnection() first to handle version checks and backups. */ export async function getConnection(): Promise { if (_dbConnection) { @@ -59,9 +119,37 @@ export async function getConnection(): Promise { return _connectionPromise; } - _connectionPromise = openDatabase(); - _dbConnection = await _connectionPromise; - return _dbConnection; + // IMPORTANT: create the shared promise before any awaits to avoid stampede opens. + _connectionPromise = (async () => { + // If prepareConnection wasn't called, do a quick check/backup here. + if (!_versionCheckResult) { + const result = await prepareConnection(); + if (shouldBlockApp(result)) { + throw new DbError('Version', 'connection', 'system', result.message); + } + } + + try { + const db = await openDatabase(); + + // Migration succeeded - mark backup as completed and schedule cleanup + if (_versionCheckResult?.requiresBackup) { + markBackupCompleted(); + cleanupOldBackups().catch((e) => console.warn('[Connection] Backup cleanup failed:', e)); + } + + _dbConnection = db; + return db; + } catch (error) { + // Migration failed - mark backup as failed so restore is available + if (_versionCheckResult?.requiresBackup) { + markBackupFailed(); + } + throw error; + } + })(); + + return _connectionPromise; } /** diff --git a/services/db/core/errors.ts b/services/db/core/errors.ts index bcd9059..48fc0e0 100644 --- a/services/db/core/errors.ts +++ b/services/db/core/errors.ts @@ -5,7 +5,7 @@ * Maps browser IndexedDB errors to typed, actionable error categories. */ -export type DbErrorKind = +export type DbErrorKind = | 'Blocked' // Another tab has DB open for upgrade | 'Upgrade' // Version/schema upgrade needed | 'Quota' // Storage quota exceeded @@ -13,7 +13,8 @@ export type DbErrorKind = | 'NotFound' // Record not found | 'Constraint' // Unique constraint or validation error | 'Permission' // Access denied (private browsing, etc.) - | 'Timeout'; // Operation timed out + | 'Timeout' // Operation timed out + | 'Version'; // DB version incompatible (newer than app) export class DbError extends Error { constructor( @@ -32,7 +33,7 @@ export class DbError extends Error { } get requiresUserAction(): boolean { - return this.kind === 'Quota' || this.kind === 'Permission'; + return this.kind === 'Quota' || this.kind === 'Permission' || this.kind === 'Version'; } } diff --git a/services/db/core/migrationBackup.ts b/services/db/core/migrationBackup.ts new file mode 100644 index 0000000..1be977d --- /dev/null +++ b/services/db/core/migrationBackup.ts @@ -0,0 +1,194 @@ +/** + * Migration Backup Service + * + * Creates automatic backups before schema migrations to prevent data loss. + * Orchestrates backup creation using tiered storage from backupStorage.ts + */ + +import { STORE_NAMES } from './schema'; +import { + type MigrationBackupMetadata, + type BackupData, + needsPreMigrationBackup, + getBackupMetadata, + clearBackupMetadata, +} from './migrationTypes'; +import { storeBackup, cleanupStorageTier } from './backupStorage'; + +// Re-export commonly used functions from migrationTypes +export { + needsPreMigrationBackup, + getBackupMetadata, + markBackupCompleted, + markBackupFailed, + type MigrationBackupMetadata, +} from './migrationTypes'; + +/** + * Create a backup before migration. + * IMPORTANT: Must be called BEFORE opening DB with new version. + */ +export async function createPreMigrationBackup( + dbName: string, + fromVersion: number, + toVersion: number +): Promise { + if (!needsPreMigrationBackup(fromVersion, toVersion)) { + console.log('[MigrationBackup] No backup needed (fresh install or same version)'); + return true; + } + + console.log(`[MigrationBackup] Creating backup before v${fromVersion} → v${toVersion}`); + + try { + // Open DB at current (old) version in read-only mode + const oldDb = await openDbReadOnly(dbName, fromVersion); + + // Export all data + const backupData = await exportAllStores(oldDb, fromVersion, toVersion); + oldDb.close(); + + const backupJson = JSON.stringify(backupData); + const sizeBytes = new Blob([backupJson]).size; + + console.log(`[MigrationBackup] Backup size: ${(sizeBytes / 1024 / 1024).toFixed(2)}MB`); + + // Update metadata with size + backupData.metadata.sizeBytes = sizeBytes; + + // Try storage strategies in order of preference + const stored = await storeBackup(backupJson, backupData.metadata); + + if (!stored) { + console.error('[MigrationBackup] Failed to store backup in any storage tier'); + return false; + } + + console.log(`[MigrationBackup] Backup created successfully in ${backupData.metadata.storage}`); + return true; + + } catch (error) { + console.error('[MigrationBackup] Failed to create backup:', error); + return false; + } +} + +/** + * Open DB at specific version in read-only mode (no upgrade) + */ +async function openDbReadOnly(dbName: string, version: number): Promise { + return new Promise((resolve, reject) => { + const request = indexedDB.open(dbName, version); + + request.onsuccess = () => resolve(request.result); + request.onerror = () => reject(new Error(`Failed to open DB: ${request.error?.message}`)); + + // If onupgradeneeded fires, abort - we only want to read existing data + request.onupgradeneeded = () => { + request.transaction?.abort(); + reject(new Error('Cannot open DB at older version for backup')); + }; + }); +} + +/** + * Export all data from all stores + */ +async function exportAllStores( + db: IDBDatabase, + fromVersion: number, + toVersion: number +): Promise { + const timestamp = new Date().toISOString(); + + const storeNames = Array.from(db.objectStoreNames); + const tx = db.transaction(storeNames, 'readonly'); + + const getAll = (storeName: string): Promise => { + return new Promise((resolve, reject) => { + if (!db.objectStoreNames.contains(storeName)) { + resolve([]); + return; + } + const store = tx.objectStore(storeName); + const request = store.getAll(); + request.onsuccess = () => resolve(request.result || []); + request.onerror = () => reject(request.error); + }); + }; + + const [ + chapters, + translations, + settings, + feedback, + promptTemplates, + urlMappings, + novels, + chapterSummaries, + amendmentLogs, + diffResults, + ] = await Promise.all([ + getAll(STORE_NAMES.CHAPTERS), + getAll(STORE_NAMES.TRANSLATIONS), + getAll(STORE_NAMES.SETTINGS), + getAll(STORE_NAMES.FEEDBACK), + getAll(STORE_NAMES.PROMPT_TEMPLATES), + getAll(STORE_NAMES.URL_MAPPINGS), + getAll(STORE_NAMES.NOVELS), + getAll(STORE_NAMES.CHAPTER_SUMMARIES), + getAll(STORE_NAMES.AMENDMENT_LOGS), + getAll(STORE_NAMES.DIFF_RESULTS), + ]); + + const metadata: MigrationBackupMetadata = { + fromVersion, + toVersion, + timestamp, + chapterCount: chapters.length, + translationCount: translations.length, + sizeBytes: 0, // Will be updated after serialization + status: 'pending', + storage: 'localStorage', // Default, will be updated + }; + + return { + metadata, + chapters, + translations, + settings, + feedback, + promptTemplates, + urlMappings, + novels, + chapterSummaries, + amendmentLogs, + diffResults, + }; +} + +/** + * Clean up old completed backups (call periodically) + */ +export async function cleanupOldBackups( + maxAgeMs: number = 7 * 24 * 60 * 60 * 1000 +): Promise { + const metadata = getBackupMetadata(); + + if (!metadata || metadata.status !== 'completed') { + return; // Keep pending/failed backups + } + + const backupAge = Date.now() - new Date(metadata.timestamp).getTime(); + + if (backupAge < maxAgeMs) { + return; // Backup is still fresh + } + + console.log('[MigrationBackup] Cleaning up old backup...'); + + await cleanupStorageTier(metadata); + clearBackupMetadata(); + + console.log('[MigrationBackup] Old backup cleaned up'); +} diff --git a/services/db/core/migrationRestore.ts b/services/db/core/migrationRestore.ts new file mode 100644 index 0000000..60fab16 --- /dev/null +++ b/services/db/core/migrationRestore.ts @@ -0,0 +1,212 @@ +/** + * Migration Restore Service + * + * Restores user data from the most recent pre-migration backup after a failed upgrade. + * + * Notes: + * - This module intentionally does NOT import `connection.ts` to avoid circular dependencies. + * - Use `restoreFromBackup()` from a recovery UI or manual admin flow. + */ + +import { applyMigrations, STORE_NAMES } from './schema'; +import { + getBackupMetadata, + clearBackupMetadata, + type BackupData, + type MigrationBackupMetadata, + type RestoreResult, +} from './migrationTypes'; +import { retrieveBackupData, parseBackupJson } from './restoreStorage'; +import { cleanupStorageTier } from './backupStorage'; + +export interface RestoreInfo { + available: boolean; + metadata: MigrationBackupMetadata | null; + reason?: string; +} + +export function canRestoreFromBackup(): boolean { + const metadata = getBackupMetadata(); + return metadata !== null && metadata.status === 'failed'; +} + +export function getRestoreInfo(): RestoreInfo { + const metadata = getBackupMetadata(); + + if (!metadata) { + return { available: false, metadata: null, reason: 'No backup metadata found' }; + } + + if (metadata.status === 'completed') { + return { available: false, metadata, reason: 'Backup already marked completed' }; + } + + if (metadata.status === 'pending') { + return { available: false, metadata, reason: 'Migration still in progress (backup pending)' }; + } + + return { available: true, metadata }; +} + +export async function restoreFromBackup(dbName: string = 'lexicon-forge'): Promise { + const metadata = getBackupMetadata(); + + if (!metadata) { + return { success: false, message: 'No backup metadata found' }; + } + + if (metadata.status !== 'failed') { + return { success: false, message: `Cannot restore from backup with status: ${metadata.status}` }; + } + + console.log(`[MigrationRestore] Starting restore from ${metadata.storage} backup (target v${metadata.fromVersion})`); + + try { + const backup = await retrieveBackupData(metadata); + if (!backup) { + return { success: false, message: 'Could not retrieve backup data' }; + } + + await deleteDatabase(dbName); + + const db = await createDatabaseAtVersion(dbName, metadata.fromVersion); + const restoredCounts = await restoreAllStores(db, backup); + db.close(); + + // Mark restore complete by clearing metadata; backup artifacts are cleaned up after. + clearBackupMetadata(); + await cleanupStorageTier(metadata); + + return { + success: true, + message: `Successfully restored ${restoredCounts.chapters} chapters and ${restoredCounts.translations} translations`, + restoredVersion: metadata.fromVersion, + recordsRestored: restoredCounts, + }; + } catch (error) { + console.error('[MigrationRestore] Restore failed:', error); + return { + success: false, + message: `Restore failed: ${error instanceof Error ? error.message : String(error)}`, + }; + } +} + +export async function emergencyRestore( + backupJson: string, + dbName: string = 'lexicon-forge' +): Promise { + const parsed = parseBackupJson(backupJson); + if (!parsed) { + return { success: false, message: 'Invalid backup file format' }; + } + + console.log(`[MigrationRestore] Emergency restore requested (target v${parsed.metadata.fromVersion})`); + + try { + await deleteDatabase(dbName); + const db = await createDatabaseAtVersion(dbName, parsed.metadata.fromVersion); + const restoredCounts = await restoreAllStores(db, parsed); + db.close(); + + // Clear any lingering metadata and stored backup pointers. + clearBackupMetadata(); + + return { + success: true, + message: `Successfully restored ${restoredCounts.chapters} chapters and ${restoredCounts.translations} translations`, + restoredVersion: parsed.metadata.fromVersion, + recordsRestored: restoredCounts, + }; + } catch (error) { + console.error('[MigrationRestore] Emergency restore failed:', error); + return { + success: false, + message: `Emergency restore failed: ${error instanceof Error ? error.message : String(error)}`, + }; + } +} + +async function deleteDatabase(dbName: string): Promise { + return new Promise((resolve, reject) => { + const request = indexedDB.deleteDatabase(dbName); + request.onsuccess = () => resolve(); + request.onerror = () => reject(request.error || new Error('Failed to delete database')); + request.onblocked = () => reject(new Error('Database deletion blocked - close other tabs')); + }); +} + +async function createDatabaseAtVersion(dbName: string, version: number): Promise { + return new Promise((resolve, reject) => { + const request = indexedDB.open(dbName, version); + + request.onupgradeneeded = (event) => { + const db = request.result; + const tx = request.transaction!; + const oldVersion = event.oldVersion; + const newVersion = event.newVersion ?? version; + applyMigrations(db, tx, oldVersion, newVersion); + }; + + request.onsuccess = () => resolve(request.result); + request.onerror = () => reject(request.error || new Error('Failed to open database for restore')); + request.onblocked = () => reject(new Error('Database open blocked - close other tabs')); + }); +} + +async function restoreAllStores(db: IDBDatabase, backup: BackupData): Promise> { + const storeData: Array<[string, any[]]> = [ + [STORE_NAMES.CHAPTERS, backup.chapters], + [STORE_NAMES.TRANSLATIONS, backup.translations], + [STORE_NAMES.SETTINGS, backup.settings], + [STORE_NAMES.FEEDBACK, backup.feedback], + [STORE_NAMES.PROMPT_TEMPLATES, backup.promptTemplates], + [STORE_NAMES.URL_MAPPINGS, backup.urlMappings], + [STORE_NAMES.NOVELS, backup.novels], + [STORE_NAMES.CHAPTER_SUMMARIES, backup.chapterSummaries], + [STORE_NAMES.AMENDMENT_LOGS, backup.amendmentLogs], + [STORE_NAMES.DIFF_RESULTS, backup.diffResults], + ]; + + const existing = new Set(Array.from(db.objectStoreNames)); + const storesToRestore = storeData.filter(([name]) => existing.has(name)); + + const restored: NonNullable = { + chapters: backup.chapters.length, + translations: backup.translations.length, + settings: backup.settings.length, + feedback: backup.feedback.length, + other: + backup.promptTemplates.length + + backup.urlMappings.length + + backup.novels.length + + backup.chapterSummaries.length + + backup.amendmentLogs.length + + backup.diffResults.length, + }; + + if (storesToRestore.length === 0) { + return restored; + } + + await new Promise((resolve, reject) => { + const tx = db.transaction( + storesToRestore.map(([name]) => name), + 'readwrite' + ); + + tx.oncomplete = () => resolve(); + tx.onerror = () => reject(tx.error || new Error('Restore transaction failed')); + tx.onabort = () => reject(tx.error || new Error('Restore transaction aborted')); + + for (const [storeName, records] of storesToRestore) { + const store = tx.objectStore(storeName); + for (const record of records) { + store.put(record); + } + } + }); + + return restored; +} + diff --git a/services/db/core/versionGate.ts b/services/db/core/versionGate.ts new file mode 100644 index 0000000..4302b66 --- /dev/null +++ b/services/db/core/versionGate.ts @@ -0,0 +1,269 @@ +/** + * Version Gate Service + * + * Checks database version before opening to handle: + * - DB newer than app (can't downgrade - must update app) + * - DB older than app (needs migration with backup) + * - DB corrupted (offer restore or fresh start) + */ + +import { getBackupMetadata, needsPreMigrationBackup } from './migrationTypes'; +import { canRestoreFromBackup } from './migrationRestore'; + +export type VersionCheckStatus = + | 'ok' // Same version, proceed normally + | 'fresh-install' // No existing DB + | 'upgrade-needed' // DB older, migration required + | 'db-newer' // DB newer than app, can't open + | 'db-corrupted' // Can't read DB version + | 'migration-failed' // Previous migration failed, restore available + | 'blocked'; // Another connection is blocking + +export interface VersionCheckResult { + status: VersionCheckStatus; + currentDbVersion: number | null; + expectedVersion: number; + canProceed: boolean; + requiresBackup: boolean; + message: string; + action?: 'update-app' | 'restore-backup' | 'create-backup' | 'fresh-start'; +} + +/** + * Check database version without triggering an upgrade. + * This should be called BEFORE getConnection(). + */ +export async function checkDatabaseVersion( + dbName: string, + expectedVersion: number +): Promise { + + // First check if there's a failed migration we need to handle + if (canRestoreFromBackup()) { + const metadata = getBackupMetadata(); + return { + status: 'migration-failed', + currentDbVersion: metadata?.fromVersion ?? null, + expectedVersion, + canProceed: false, + requiresBackup: false, + message: `Previous migration to v${metadata?.toVersion} failed. A backup from v${metadata?.fromVersion} is available.`, + action: 'restore-backup', + }; + } + + try { + const currentVersion = await peekDbVersion(dbName); + + // Fresh install - no existing database + if (currentVersion === null) { + return { + status: 'fresh-install', + currentDbVersion: null, + expectedVersion, + canProceed: true, + requiresBackup: false, + message: 'Fresh installation, no existing data.', + }; + } + + // Same version - all good + if (currentVersion === expectedVersion) { + return { + status: 'ok', + currentDbVersion: currentVersion, + expectedVersion, + canProceed: true, + requiresBackup: false, + message: 'Database version matches app version.', + }; + } + + // DB is older - needs upgrade with backup + if (currentVersion < expectedVersion) { + return { + status: 'upgrade-needed', + currentDbVersion: currentVersion, + expectedVersion, + canProceed: true, // Can proceed after backup + requiresBackup: needsPreMigrationBackup(currentVersion, expectedVersion), + message: `Database will be upgraded from v${currentVersion} to v${expectedVersion}. A backup will be created first.`, + action: 'create-backup', + }; + } + + // DB is newer than app - CANNOT proceed + return { + status: 'db-newer', + currentDbVersion: currentVersion, + expectedVersion, + canProceed: false, + requiresBackup: false, + message: + `Your database (v${currentVersion}) is from a newer version of the app (expects v${expectedVersion}). ` + + `Please update the app or use the newer version to export your data.`, + action: 'update-app', + }; + + } catch (error) { + // Check if it's a blocked error + if (error instanceof Error && error.message.includes('blocked')) { + return { + status: 'blocked', + currentDbVersion: null, + expectedVersion, + canProceed: false, + requiresBackup: false, + message: 'Database is being used by another tab. Please close other LexiconForge tabs and try again.', + }; + } + + // Unknown error - likely corrupted + return { + status: 'db-corrupted', + currentDbVersion: null, + expectedVersion, + canProceed: false, + requiresBackup: false, + message: `Could not read database: ${error instanceof Error ? error.message : String(error)}`, + action: 'fresh-start', + }; + } +} + +/** + * Peek at the current database version without triggering any upgrades. + * Returns null if database doesn't exist. + */ +async function peekDbVersion(dbName: string): Promise { + if (typeof (indexedDB as any).databases === 'function') { + try { + const databases = await (indexedDB as any).databases(); + const record = databases.find((db: any) => db?.name === dbName); + return record?.version ?? null; + } catch { + // fall through to open-based detection + } + } + + return await openToCheckVersion(dbName); +} + +/** + * Fallback version check by opening DB + */ +async function openToCheckVersion(dbName: string): Promise { + return new Promise((resolve, reject) => { + const request = indexedDB.open(dbName); + let resolved = false; + + request.onsuccess = () => { + if (resolved) return; + const db = request.result; + const version = db.version; + db.close(); + resolved = true; + resolve(version); + }; + + request.onerror = () => { + if (resolved) return; + if (request.error?.name === 'NotFoundError') { + resolved = true; + resolve(null); + return; + } + reject(new Error(request.error?.message || 'Failed to open database')); + }; + + request.onblocked = () => { + reject(new Error('Database access blocked by another connection')); + }; + + request.onupgradeneeded = (event) => { + // If oldVersion === 0 we would be creating a brand-new DB. Abort so we don't create anything. + if (event.oldVersion === 0) { + try { + request.transaction?.abort(); + } catch {} + resolved = true; + resolve(null); + } + }; + }); +} + +/** + * Format a version check result for logging + */ +export function formatVersionCheck(result: VersionCheckResult): string { + const parts = [ + `Status: ${result.status}`, + `DB Version: ${result.currentDbVersion ?? 'none'}`, + `App Version: ${result.expectedVersion}`, + `Can Proceed: ${result.canProceed}`, + `Requires Backup: ${result.requiresBackup}`, + ]; + + if (result.action) { + parts.push(`Action: ${result.action}`); + } + + return parts.join(' | '); +} + +/** + * Check if app should show upgrade UI + */ +export function shouldShowUpgradeNotice(result: VersionCheckResult): boolean { + return result.status === 'upgrade-needed' && result.requiresBackup; +} + +/** + * Check if app should block and show error + */ +export function shouldBlockApp(result: VersionCheckResult): boolean { + return !result.canProceed; +} + +/** + * Get user-friendly title for the status + */ +export function getStatusTitle(status: VersionCheckStatus): string { + switch (status) { + case 'ok': + return 'Ready'; + case 'fresh-install': + return 'Welcome'; + case 'upgrade-needed': + return 'Database Upgrade'; + case 'db-newer': + return 'App Update Required'; + case 'db-corrupted': + return 'Database Error'; + case 'migration-failed': + return 'Migration Failed'; + case 'blocked': + return 'Database Busy'; + default: + return 'Database Status'; + } +} + +/** + * Get user-friendly action button text + */ +export function getActionButtonText(action?: string): string { + switch (action) { + case 'update-app': + return 'Check for Updates'; + case 'restore-backup': + return 'Restore from Backup'; + case 'create-backup': + return 'Continue with Backup'; + case 'fresh-start': + return 'Start Fresh'; + default: + return 'Continue'; + } +} diff --git a/tests/db/migrations/migrationBackup.test.ts b/tests/db/migrations/migrationBackup.test.ts new file mode 100644 index 0000000..2c05e6f --- /dev/null +++ b/tests/db/migrations/migrationBackup.test.ts @@ -0,0 +1,103 @@ +import { describe, it, expect, afterEach } from 'vitest'; +import { createPreMigrationBackup } from '../../../services/db/core/migrationBackup'; +import { retrieveBackupData } from '../../../services/db/core/restoreStorage'; +import { + BACKUP_DB_NAME, + BACKUP_METADATA_KEY, + getBackupMetadata, + type MigrationBackupMetadata, +} from '../../../services/db/core/migrationTypes'; +import { SCHEMA_VERSIONS, STORE_NAMES, applyMigrations } from '../../../services/db/core/schema'; + +const TEST_DB_NAME = 'test-migration-backup'; +const FROM_VERSION = SCHEMA_VERSIONS.CURRENT - 1; +const TO_VERSION = SCHEMA_VERSIONS.CURRENT; + +async function openDbAtVersion(version: number): Promise { + return new Promise((resolve, reject) => { + const request = indexedDB.open(TEST_DB_NAME, version); + request.onerror = () => reject(request.error); + request.onsuccess = () => resolve(request.result); + request.onupgradeneeded = (event) => { + const db = request.result; + const tx = request.transaction!; + applyMigrations(db, tx, event.oldVersion, event.newVersion ?? version); + }; + }); +} + +async function deleteDb(name: string): Promise { + await new Promise((resolve, reject) => { + const request = indexedDB.deleteDatabase(name); + request.onsuccess = () => resolve(); + request.onerror = () => reject(request.error); + request.onblocked = () => reject(new Error('blocked')); + }); +} + +describe('MigrationBackup', () => { + afterEach(async () => { + try { + await deleteDb(TEST_DB_NAME); + } catch { + // ignore + } + try { + await deleteDb(BACKUP_DB_NAME); + } catch { + // ignore + } + localStorage.removeItem(BACKUP_METADATA_KEY); + }); + + it('creates a backup for an upgrade and stores metadata', async () => { + const db = await openDbAtVersion(FROM_VERSION); + + // Seed a few stores with minimal records + await new Promise((resolve, reject) => { + const tx = db.transaction( + [ + STORE_NAMES.CHAPTERS, + STORE_NAMES.TRANSLATIONS, + STORE_NAMES.SETTINGS, + STORE_NAMES.FEEDBACK, + STORE_NAMES.PROMPT_TEMPLATES, + STORE_NAMES.URL_MAPPINGS, + ], + 'readwrite' + ); + + tx.oncomplete = () => resolve(); + tx.onerror = () => reject(tx.error); + + tx.objectStore(STORE_NAMES.CHAPTERS).put({ url: 'u1', title: 't1', content: 'c1' }); + tx.objectStore(STORE_NAMES.TRANSLATIONS).put({ id: 'tr1', chapterUrl: 'u1', version: 1, isActive: true }); + tx.objectStore(STORE_NAMES.SETTINGS).put({ key: 'k1', value: 'v1' }); + tx.objectStore(STORE_NAMES.FEEDBACK).put({ id: 'fb1', chapterUrl: 'u1', createdAt: Date.now() }); + tx.objectStore(STORE_NAMES.PROMPT_TEMPLATES).put({ id: 'p1', name: 'n1', isDefault: true, createdAt: Date.now() }); + tx.objectStore(STORE_NAMES.URL_MAPPINGS).put({ url: 'u1', stableId: 's1', isCanonical: true }); + }); + + db.close(); + + const ok = await createPreMigrationBackup(TEST_DB_NAME, FROM_VERSION, TO_VERSION); + expect(ok).toBe(true); + + const metadata = getBackupMetadata() as MigrationBackupMetadata | null; + expect(metadata).not.toBeNull(); + expect(metadata?.fromVersion).toBe(FROM_VERSION); + expect(metadata?.toVersion).toBe(TO_VERSION); + expect(metadata?.status).toBe('pending'); + expect(metadata?.chapterCount).toBe(1); + expect(metadata?.translationCount).toBe(1); + + // Confirm we can retrieve backup data from the selected tier + const backup = metadata ? await retrieveBackupData(metadata) : null; + expect(backup).not.toBeNull(); + expect(backup?.chapters).toHaveLength(1); + expect(backup?.translations).toHaveLength(1); + expect(backup?.promptTemplates).toHaveLength(1); + expect(backup?.urlMappings).toHaveLength(1); + }); +}); + diff --git a/tests/db/migrations/migrationRestore.test.ts b/tests/db/migrations/migrationRestore.test.ts new file mode 100644 index 0000000..e32ba3a --- /dev/null +++ b/tests/db/migrations/migrationRestore.test.ts @@ -0,0 +1,93 @@ +import { describe, it, expect, afterEach } from 'vitest'; +import { restoreFromBackup } from '../../../services/db/core/migrationRestore'; +import { + BACKUP_DATA_KEY, + BACKUP_METADATA_KEY, + type MigrationBackupMetadata, + type BackupData, +} from '../../../services/db/core/migrationTypes'; +import { SCHEMA_VERSIONS, STORE_NAMES } from '../../../services/db/core/schema'; + +const TEST_DB_NAME = 'test-migration-restore'; +const FROM_VERSION = SCHEMA_VERSIONS.CURRENT - 1; + +async function deleteDb(): Promise { + await new Promise((resolve, reject) => { + const request = indexedDB.deleteDatabase(TEST_DB_NAME); + request.onsuccess = () => resolve(); + request.onerror = () => reject(request.error); + request.onblocked = () => reject(new Error('blocked')); + }); +} + +describe('MigrationRestore', () => { + afterEach(async () => { + try { + await deleteDb(); + } catch { + // ignore + } + localStorage.removeItem(BACKUP_METADATA_KEY); + localStorage.removeItem(BACKUP_DATA_KEY); + }); + + it('restores DB contents from a localStorage backup', async () => { + const metadata: MigrationBackupMetadata = { + fromVersion: FROM_VERSION, + toVersion: SCHEMA_VERSIONS.CURRENT, + timestamp: new Date().toISOString(), + chapterCount: 1, + translationCount: 1, + sizeBytes: 0, + status: 'failed', + storage: 'localStorage', + }; + + const backup: BackupData = { + metadata, + chapters: [{ url: 'u1', title: 't1', content: 'c1' }], + translations: [{ id: 'tr1', chapterUrl: 'u1', version: 1, isActive: true }], + settings: [{ key: 'k1', value: 'v1' }], + feedback: [{ id: 'fb1', chapterUrl: 'u1', createdAt: Date.now() }], + promptTemplates: [{ id: 'p1', name: 'n1', isDefault: true, createdAt: Date.now() }], + urlMappings: [{ url: 'u1', stableId: 's1', isCanonical: true }], + novels: [], + chapterSummaries: [], + amendmentLogs: [], + diffResults: [], + }; + + const json = JSON.stringify(backup); + metadata.sizeBytes = new Blob([json]).size; + + localStorage.setItem(BACKUP_METADATA_KEY, JSON.stringify(metadata)); + localStorage.setItem(BACKUP_DATA_KEY, json); + + const result = await restoreFromBackup(TEST_DB_NAME); + expect(result.success).toBe(true); + expect(result.restoredVersion).toBe(FROM_VERSION); + + expect(localStorage.getItem(BACKUP_METADATA_KEY)).toBeNull(); + expect(localStorage.getItem(BACKUP_DATA_KEY)).toBeNull(); + + const db = await new Promise((resolve, reject) => { + const request = indexedDB.open(TEST_DB_NAME); + request.onerror = () => reject(request.error); + request.onsuccess = () => resolve(request.result); + }); + + expect(db.version).toBe(FROM_VERSION); + + const chapter = await new Promise((resolve, reject) => { + const tx = db.transaction(STORE_NAMES.CHAPTERS, 'readonly'); + const req = tx.objectStore(STORE_NAMES.CHAPTERS).get('u1'); + req.onsuccess = () => resolve(req.result); + req.onerror = () => reject(req.error); + }); + + expect(chapter?.title).toBe('t1'); + + db.close(); + }); +}); + diff --git a/tests/db/migrations/versionGate.test.ts b/tests/db/migrations/versionGate.test.ts new file mode 100644 index 0000000..0a1becc --- /dev/null +++ b/tests/db/migrations/versionGate.test.ts @@ -0,0 +1,91 @@ +import { describe, it, expect, afterEach } from 'vitest'; +import { checkDatabaseVersion } from '../../../services/db/core/versionGate'; +import { BACKUP_METADATA_KEY, type MigrationBackupMetadata } from '../../../services/db/core/migrationTypes'; +import { SCHEMA_VERSIONS } from '../../../services/db/core/schema'; + +const TEST_DB_NAME = 'test-version-gate'; +const EXPECTED_VERSION = SCHEMA_VERSIONS.CURRENT; + +async function createDb(version: number): Promise { + const db = await new Promise((resolve, reject) => { + const request = indexedDB.open(TEST_DB_NAME, version); + request.onerror = () => reject(request.error); + request.onsuccess = () => resolve(request.result); + request.onupgradeneeded = () => { + // Ensure at least one store exists so the DB is created. + const db = request.result; + if (!db.objectStoreNames.contains('dummy')) { + db.createObjectStore('dummy', { keyPath: 'id' }); + } + }; + }); + db.close(); +} + +async function deleteDb(): Promise { + await new Promise((resolve, reject) => { + const request = indexedDB.deleteDatabase(TEST_DB_NAME); + request.onsuccess = () => resolve(); + request.onerror = () => reject(request.error); + request.onblocked = () => reject(new Error('blocked')); + }); +} + +describe('VersionGate', () => { + afterEach(async () => { + try { + await deleteDb(); + } catch { + // ignore + } + localStorage.removeItem(BACKUP_METADATA_KEY); + }); + + it('returns fresh-install when no DB exists', async () => { + const result = await checkDatabaseVersion(TEST_DB_NAME, EXPECTED_VERSION); + expect(result.status).toBe('fresh-install'); + expect(result.canProceed).toBe(true); + }); + + it('returns ok when DB version matches app version', async () => { + await createDb(EXPECTED_VERSION); + const result = await checkDatabaseVersion(TEST_DB_NAME, EXPECTED_VERSION); + expect(result.status).toBe('ok'); + expect(result.canProceed).toBe(true); + }); + + it('returns upgrade-needed when DB is older', async () => { + await createDb(EXPECTED_VERSION - 1); + const result = await checkDatabaseVersion(TEST_DB_NAME, EXPECTED_VERSION); + expect(result.status).toBe('upgrade-needed'); + expect(result.canProceed).toBe(true); + expect(result.requiresBackup).toBe(true); + }); + + it('returns db-newer when DB is newer than app', async () => { + await createDb(EXPECTED_VERSION + 1); + const result = await checkDatabaseVersion(TEST_DB_NAME, EXPECTED_VERSION); + expect(result.status).toBe('db-newer'); + expect(result.canProceed).toBe(false); + }); + + it('returns migration-failed when failed backup metadata exists', async () => { + const metadata: MigrationBackupMetadata = { + fromVersion: EXPECTED_VERSION - 1, + toVersion: EXPECTED_VERSION, + timestamp: new Date().toISOString(), + chapterCount: 1, + translationCount: 1, + sizeBytes: 123, + status: 'failed', + storage: 'localStorage', + }; + localStorage.setItem(BACKUP_METADATA_KEY, JSON.stringify(metadata)); + + const result = await checkDatabaseVersion(TEST_DB_NAME, EXPECTED_VERSION); + expect(result.status).toBe('migration-failed'); + expect(result.canProceed).toBe(false); + expect(result.action).toBe('restore-backup'); + }); +}); +