Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions docs/WORKLOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/*`
98 changes: 93 additions & 5 deletions services/db/core/connection.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -34,6 +51,7 @@ export function isIndexedDBAvailable(): boolean {
// Connection singleton
let _dbConnection: IDBDatabase | null = null;
let _connectionPromise: Promise<IDBDatabase> | null = null;
let _versionCheckResult: VersionCheckResult | null = null;

export function resetConnection(): void {
if (_dbConnection) {
Expand All @@ -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<VersionCheckResult> {
// 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<IDBDatabase> {
if (_dbConnection) {
Expand All @@ -59,9 +119,37 @@ export async function getConnection(): Promise<IDBDatabase> {
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;
}

/**
Expand Down
7 changes: 4 additions & 3 deletions services/db/core/errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,15 +5,16 @@
* 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
| 'Transient' // Temporary failure, safe to retry
| '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(
Expand All @@ -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';
}
}

Expand Down
194 changes: 194 additions & 0 deletions services/db/core/migrationBackup.ts
Original file line number Diff line number Diff line change
@@ -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<boolean> {
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<IDBDatabase> {
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<BackupData> {
const timestamp = new Date().toISOString();

const storeNames = Array.from(db.objectStoreNames);
const tx = db.transaction(storeNames, 'readonly');

const getAll = (storeName: string): Promise<any[]> => {
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<void> {
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');
}
Loading
Loading