diff --git a/src/services/platforms/CapacitorPlatformService.ts b/src/services/platforms/CapacitorPlatformService.ts index fe804f8e..42dd01a8 100644 --- a/src/services/platforms/CapacitorPlatformService.ts +++ b/src/services/platforms/CapacitorPlatformService.ts @@ -50,6 +50,8 @@ export class CapacitorPlatformService private sqlite: SQLiteConnection; private db: SQLiteDBConnection | null = null; private dbName = "timesafari.sqlite"; + private dbVersion: number = 1; + private encryption: string = "no-encryption"; private initialized = false; private initializationPromise: Promise | null = null; private operationQueue: Array = []; @@ -85,120 +87,396 @@ export class CapacitorPlatformService } } - private async _initialize(): Promise { - if (this.initialized) { + /** + * Extract error message from unknown error type. + * + * Provides consistent error text extraction for logging and + * error handling across different error formats. + * + * @param e - Unknown error object + * @returns String representation of error message + * @private + */ + private _asMessage(e: unknown): string { + if (e instanceof Error) { + return e.message; + } + if ( + typeof e === "object" && + e !== null && + ("errorMessage" in e || "message" in e) + ) { + return ( + (e as { errorMessage?: string; message?: string }).errorMessage || + (e as { errorMessage?: string; message?: string }).message || + String(e) + ); + } + return String(e); + } + + /** + * Ensure database connection exists, handling JS/native map desyncs. + * + * Uses capability checks instead of error message parsing to + * deterministically handle connection state across native/JS + * boundaries. + * + * @returns Promise resolving when connection is established + * @throws Error if connection cannot be established + * @private + */ + private async _ensureConnection(): Promise { + const startTime = Date.now(); + + // Check if connection exists in JavaScript Map + const isConnResult = await this.sqlite.isConnection(this.dbName, false); + + if (isConnResult.result) { + // Connection exists in Map, retrieve it + logger.debug( + `[CapacitorPlatformService] Connection exists in Map, retrieving (${Date.now() - startTime}ms)`, + ); + this.db = await this.sqlite.retrieveConnection(this.dbName, false); + logger.debug( + `[CapacitorPlatformService] Successfully retrieved connection (${Date.now() - startTime}ms)`, + ); return; } + // Connection doesn't exist in Map, try to create try { - // Try to create/Open database connection + logger.debug( + `[CapacitorPlatformService] Creating new connection (${Date.now() - startTime}ms)`, + ); + this.db = await this.sqlite.createConnection( + this.dbName, + false, + this.encryption, + this.dbVersion, + false, + ); + logger.debug( + `[CapacitorPlatformService] Successfully created connection (${Date.now() - startTime}ms)`, + ); + } catch (createError: unknown) { + // Connection might exist natively but not in JS Map + // Best-effort cleanup then retry once + logger.debug( + `[CapacitorPlatformService] Create failed, attempting cleanup (${Date.now() - startTime}ms):`, + this._asMessage(createError), + ); try { - this.db = await this.sqlite.createConnection( - this.dbName, - false, - "no-encryption", - 1, - false, + await this.sqlite.closeConnection(this.dbName, false); + } catch (closeError) { + // Ignore close errors - connection might not be properly tracked + logger.debug( + `[CapacitorPlatformService] Cleanup close error (may be expected):`, + this._asMessage(closeError), ); - } catch (createError: unknown) { - // If connection already exists, try to retrieve it or handle gracefully - const errorMessage = - createError instanceof Error - ? createError.message - : String(createError); - const errorObj = - typeof createError === "object" && createError !== null - ? (createError as { errorMessage?: string; message?: string }) - : {}; - - const fullErrorMessage = - errorObj.errorMessage || errorObj.message || errorMessage; - - if (fullErrorMessage.includes("already exists")) { - logger.debug( - "[CapacitorPlatformService] Connection already exists on native side, attempting to retrieve", - ); - // Check if connection exists in JavaScript Map - const isConnResult = await this.sqlite.isConnection( - this.dbName, - false, - ); - if (isConnResult.result) { - // Connection exists in Map, retrieve it - this.db = await this.sqlite.retrieveConnection(this.dbName, false); - logger.debug( - "[CapacitorPlatformService] Successfully retrieved existing connection from Map", - ); - } else { - // Connection exists on native side but not in JavaScript Map - // This can happen when the app is restarted but native connections persist - // Try to close the native connection first, then create a new one - logger.debug( - "[CapacitorPlatformService] Connection exists natively but not in Map, closing and recreating", - ); - try { - await this.sqlite.closeConnection(this.dbName, false); - } catch (closeError) { - // Ignore close errors - connection might not be properly tracked - logger.debug( - "[CapacitorPlatformService] Error closing connection (may be expected):", - closeError, - ); - } - // Now try to create the connection again - this.db = await this.sqlite.createConnection( - this.dbName, - false, - "no-encryption", - 1, - false, - ); - logger.debug( - "[CapacitorPlatformService] Successfully created connection after cleanup", - ); - } - } else { - // Re-throw if it's a different error - throw createError; - } } - // Open the connection if it's not already open + // Retry create once + logger.debug( + `[CapacitorPlatformService] Retrying connection creation (${Date.now() - startTime}ms)`, + ); + this.db = await this.sqlite.createConnection( + this.dbName, + false, + this.encryption, + this.dbVersion, + false, + ); + logger.debug( + `[CapacitorPlatformService] Successfully created connection after retry (${Date.now() - startTime}ms)`, + ); + } + } + + /** + * Apply SQLite PRAGMAs with platform-aware configuration. + * + * Sets foreign keys, journal mode (WAL on native), synchronous + * mode, and busy timeout. Handles platform differences gracefully. + * + * @param db - Database connection (non-null) + * @returns Promise resolving when PRAGMAs are applied + * @throws Error if PRAGMA application fails + * @private + */ + private async _applyPragmas(db: SQLiteDBConnection): Promise { + const startTime = Date.now(); + const platform = Capacitor.getPlatform(); + let journalMode = "unknown"; + + try { + // Always enable foreign keys + await db.execute("PRAGMA foreign_keys = ON;"); + logger.debug( + `[CapacitorPlatformService] Applied foreign_keys PRAGMA (${Date.now() - startTime}ms)`, + ); + + // Set busy timeout before any operations + await db.execute("PRAGMA busy_timeout = 5000;"); + logger.debug( + `[CapacitorPlatformService] Applied busy_timeout PRAGMA (${Date.now() - startTime}ms)`, + ); + + // Prefer WAL on native platforms + if (platform !== "web") { + try { + await db.execute("PRAGMA journal_mode = WAL;"); + // Verify actual journal mode + const modeResult = await db.query("PRAGMA journal_mode;"); + if ( + modeResult.values && + modeResult.values.length > 0 && + typeof modeResult.values[0] === "object" && + modeResult.values[0] !== null + ) { + const row = modeResult.values[0] as Record; + journalMode = + (row.journal_mode as string) || + (Array.isArray(modeResult.values[0]) + ? (modeResult.values[0][0] as string) + : "unknown"); + } + logger.debug( + `[CapacitorPlatformService] Applied WAL journal_mode: ${journalMode} (${Date.now() - startTime}ms)`, + ); + + await db.execute("PRAGMA synchronous = NORMAL;"); + logger.debug( + `[CapacitorPlatformService] Applied synchronous PRAGMA (${Date.now() - startTime}ms)`, + ); + } catch (walError) { + logger.warn( + `[CapacitorPlatformService] WAL mode failed, continuing with default:`, + this._asMessage(walError), + ); + // Verify actual mode even if WAL failed + try { + const modeResult = await db.query("PRAGMA journal_mode;"); + if ( + modeResult.values && + modeResult.values.length > 0 && + typeof modeResult.values[0] === "object" && + modeResult.values[0] !== null + ) { + const row = modeResult.values[0] as Record; + journalMode = + (row.journal_mode as string) || + (Array.isArray(modeResult.values[0]) + ? (modeResult.values[0][0] as string) + : "unknown"); + } + } catch { + journalMode = "unknown"; + } + } + } else { + logger.debug( + `[CapacitorPlatformService] Skipping WAL on web platform (${Date.now() - startTime}ms)`, + ); + journalMode = "default"; + } + + logger.debug( + `[CapacitorPlatformService] PRAGMAs applied successfully, journal_mode: ${journalMode} (${Date.now() - startTime}ms)`, + ); + return journalMode; + } catch (error) { + logger.error( + `[CapacitorPlatformService] Failed to apply PRAGMAs (${Date.now() - startTime}ms):`, + this._asMessage(error), + ); + throw error; + } + } + + /** + * Initialize database connection, apply PRAGMAs, run migrations, + * and verify integrity. + * + * This method orchestrates the complete database initialization + * process with proper error handling, transaction management, and + * observability. It relies on `initializeDatabase()` for + * single-flight concurrency control. + * + * ## Initialization Phases: + * + * 1. **Connection**: Establish connection using capability checks + * 2. **Open**: Open connection with tolerant "already open" handling + * 3. **PRAGMAs**: Apply platform-aware SQLite configuration + * 4. **Migrations**: Execute migrations within transaction + * 5. **Verification**: Run integrity checks post-migration + * 6. **Queue Start**: Begin processing queued operations + * + * ## Error Handling: + * + * - All phases are timed and logged + * - Failures trigger cleanup (close connection, reset state) + * - Errors include original cause for upstream logging + * - Single-flight promise is reset only on failure + * + * ## Transaction Management: + * + * Migrations run within `BEGIN IMMEDIATE` transaction with + * automatic rollback on failure. This ensures database consistency + * even if migrations fail mid-way. + * + * @throws Error if initialization fails at any phase + * @private Internal method called only from initializeDatabase() + */ + private async _initialize(): Promise { + const initStartTime = Date.now(); + const phaseTimings: Record = {}; + let connectionEstablished = false; + let dbOpened = false; + let journalMode = "unknown"; + + try { + // Phase 1: Connection establishment + const connectStart = Date.now(); + await this._ensureConnection(); + phaseTimings.connect = Date.now() - connectStart; + connectionEstablished = true; + + if (!this.db) { + throw new Error("Connection established but db is null"); + } + + // Narrow type for subsequent operations + const db = this.db; + + // Phase 2: Open connection + const openStart = Date.now(); try { - await this.db.open(); + await db.open(); + dbOpened = true; } catch (openError: unknown) { - const openErrorMessage = - openError instanceof Error ? openError.message : String(openError); - // If already open, that's fine - continue - if (!openErrorMessage.includes("already open")) { + const openMsg = this._asMessage(openError); + // Swallow only "already open" errors + if (!/already open/i.test(openMsg)) { throw openError; } logger.debug( - "[CapacitorPlatformService] Database connection already open", + `[CapacitorPlatformService] Database already open (${Date.now() - openStart}ms)`, ); + dbOpened = true; + } + phaseTimings.open = Date.now() - openStart; + + // Phase 3: Apply PRAGMAs + const pragmasStart = Date.now(); + journalMode = await this._applyPragmas(db); + phaseTimings.pragmas = Date.now() - pragmasStart; + + // Phase 4: Run migrations in transaction + const migrateStart = Date.now(); + try { + await db.execute("BEGIN IMMEDIATE;"); + logger.debug( + `[CapacitorPlatformService] Migration transaction started (${Date.now() - migrateStart}ms)`, + ); + + await this.runCapacitorMigrations(); + + await db.execute("COMMIT;"); + logger.debug( + `[CapacitorPlatformService] Migration transaction committed (${Date.now() - migrateStart}ms)`, + ); + } catch (migrateError) { + // Rollback on any migration error + try { + await db.execute("ROLLBACK;"); + logger.debug( + `[CapacitorPlatformService] Migration transaction rolled back (${Date.now() - migrateStart}ms)`, + ); + } catch (rollbackError) { + logger.error( + `[CapacitorPlatformService] Rollback failed:`, + this._asMessage(rollbackError), + ); + } + throw migrateError; + } + phaseTimings.migrate = Date.now() - migrateStart; + + // Phase 5: Post-migration integrity verification + const verifyStart = Date.now(); + await this.verifyDatabaseIntegrity(); + phaseTimings.verify = Date.now() - verifyStart; + + // Mark as initialized + this.initialized = true; + + // Phase 6: Start queue processor (only once) + if (!this.isProcessingQueue) { + this.processQueue(); } - // Set journal mode to WAL for better performance - // await this.db.execute("PRAGMA journal_mode=WAL;"); - - // Run migrations - await this.runCapacitorMigrations(); - - this.initialized = true; + // Log success with all timing and config info + const totalTime = Date.now() - initStartTime; + const platform = Capacitor.getPlatform(); logger.log( - "[CapacitorPlatformService] SQLite database initialized successfully", + `[CapacitorPlatformService] Database initialized successfully ` + + `(total: ${totalTime}ms, connect: ${phaseTimings.connect}ms, ` + + `open: ${phaseTimings.open}ms, pragmas: ${phaseTimings.pragmas}ms, ` + + `migrate: ${phaseTimings.migrate}ms, verify: ${phaseTimings.verify}ms) ` + + `[dbName: ${this.dbName}, dbVersion: ${this.dbVersion}, ` + + `encryption: ${this.encryption}, platform: ${platform}, ` + + `journal_mode: ${journalMode}]`, + ); + } catch (error) { + const phase = + Object.keys(phaseTimings).length === 0 + ? "connection" + : Object.keys(phaseTimings).pop() || "unknown"; + const totalTime = Date.now() - initStartTime; + + logger.error( + `[CapacitorPlatformService] Initialization failed at phase '${phase}' ` + + `(total: ${totalTime}ms):`, + error instanceof Error ? error.message : String(error), ); - // Start processing the queue after initialization - this.processQueue(); - } catch (error) { - logger.error( - "[CapacitorPlatformService] Error initializing SQLite database:", - error, - ); - throw new Error( - "[CapacitorPlatformService] Failed to initialize database", + // Cleanup: best-effort close and reset state + if (connectionEstablished && this.db) { + try { + if (dbOpened) { + await this.db.close(); + } + } catch (closeError) { + logger.debug( + `[CapacitorPlatformService] Cleanup close error:`, + this._asMessage(closeError), + ); + } + + try { + await this.sqlite.closeConnection(this.dbName, false); + } catch (closeConnError) { + logger.debug( + `[CapacitorPlatformService] Cleanup closeConnection error:`, + this._asMessage(closeConnError), + ); + } + } + + this.db = null; + this.initialized = false; + + // Re-throw with cause for upstream logging + const initError = new Error( + `[CapacitorPlatformService] Failed to initialize database at phase '${phase}'`, ); + if (error instanceof Error) { + // Use standard Error properties for compatibility + initError.message = `${initError.message}: ${error.message}`; + initError.stack = error.stack; + } + throw initError; } } @@ -673,22 +951,13 @@ export class CapacitorPlatformService return new Set(names); }; - try { - // Execute the migration process - await runMigrations(sqlExec, sqlQuery, extractMigrationNames); - - // After migrations, run integrity check to verify database state - await this.verifyDatabaseIntegrity(); - } catch (error) { - logger.error(`❌ [CapacitorMigration] Migration failed:`, error); - // Still try to verify what we have for debugging purposes - await this.verifyDatabaseIntegrity(); - throw error; - } + // Execute the migration process + // Note: Integrity verification is handled by _initialize() after transaction commit + await runMigrations(sqlExec, sqlQuery, extractMigrationNames); } /** - * Verify database integrity and migration status + * Verify database integrity and migration status. * * This method performs comprehensive validation of the database structure * and migration state. It's designed to help identify issues with the @@ -719,9 +988,9 @@ export class CapacitorPlatformService * * ## Error Handling: * - * This method is designed to never throw errors - it captures and logs - * all validation issues for debugging purposes. This ensures that even - * if integrity checks fail, they don't prevent the application from starting. + * This method throws errors for critical failures (missing core tables) + * to ensure initialization fails when database integrity is compromised. + * Non-critical validation issues are logged but do not cause failures. * * ## Logging Output: * @@ -731,6 +1000,7 @@ export class CapacitorPlatformService * - `📊` for data summaries * - `🔍` for investigation steps * + * @throws Error if critical integrity checks fail (e.g., missing core tables) * @private Internal method called after migrations * * @example @@ -741,12 +1011,15 @@ export class CapacitorPlatformService */ private async verifyDatabaseIntegrity(): Promise { if (!this.db) { - logger.error(`❌ [DB-Integrity] Database not initialized`); - return; + const error = new Error("[DB-Integrity] Database not initialized"); + logger.error(`❌ ${error.message}`); + throw error; } logger.debug(`🔍 [DB-Integrity] Starting database integrity check...`); + const missingTables: string[] = []; + try { // Step 1: Check migrations table and applied migrations const migrationsResult = await this.db.query( @@ -763,7 +1036,6 @@ export class CapacitorPlatformService "logs", "temp", ]; - const existingTables: string[] = []; for (const tableName of coreTableNames) { try { @@ -771,58 +1043,66 @@ export class CapacitorPlatformService `SELECT name FROM sqlite_master WHERE type='table' AND name='${tableName}'`, ); if (tableCheck.values && tableCheck.values.length > 0) { - existingTables.push(tableName); logger.debug(`✅ [DB-Integrity] Table ${tableName} exists`); } else { logger.error(`❌ [DB-Integrity] Table ${tableName} missing`); + missingTables.push(tableName); } } catch (error) { logger.error( `❌ [DB-Integrity] Error checking table ${tableName}:`, error, ); + missingTables.push(tableName); } } + // Fail init if critical tables are missing + if (missingTables.length > 0) { + const error = new Error( + `[DB-Integrity] Critical tables missing: ${missingTables.join(", ")}`, + ); + logger.error(`❌ ${error.message}`); + throw error; + } + // Step 3: Check contacts table schema (including iViewContent column) - if (existingTables.includes("contacts")) { - try { - const contactsSchema = await this.db.query( - "PRAGMA table_info(contacts)", - ); + try { + const contactsSchema = await this.db.query( + "PRAGMA table_info(contacts)", + ); + logger.debug( + `📊 [DB-Integrity] Contacts table schema:`, + contactsSchema, + ); + + // Check for iViewContent column specifically + const hasIViewContent = contactsSchema.values?.some( + (col: unknown) => + (typeof col === "object" && + col !== null && + "name" in col && + (col as { name: string }).name === "iViewContent") || + (Array.isArray(col) && col[1] === "iViewContent"), + ); + + if (hasIViewContent) { logger.debug( - `📊 [DB-Integrity] Contacts table schema:`, - contactsSchema, + `✅ [DB-Integrity] iViewContent column exists in contacts table`, ); - - // Check for iViewContent column specifically - const hasIViewContent = contactsSchema.values?.some( - (col: unknown) => - (typeof col === "object" && - col !== null && - "name" in col && - (col as { name: string }).name === "iViewContent") || - (Array.isArray(col) && col[1] === "iViewContent"), - ); - - if (hasIViewContent) { - logger.debug( - `✅ [DB-Integrity] iViewContent column exists in contacts table`, - ); - } else { - logger.error( - `❌ [DB-Integrity] iViewContent column missing from contacts table`, - ); - } - } catch (error) { - logger.error( - `❌ [DB-Integrity] Error checking contacts schema:`, - error, + } else { + logger.warn( + `⚠️ [DB-Integrity] iViewContent column missing from contacts table (non-critical)`, ); } + } catch (error) { + logger.warn( + `⚠️ [DB-Integrity] Error checking contacts schema (non-critical):`, + error, + ); } - // Step 4: Check for basic data integrity + // Step 4: Check for basic data integrity (best-effort, non-critical) try { const accountCount = await this.db.query( "SELECT COUNT(*) as count FROM accounts", @@ -838,11 +1118,19 @@ export class CapacitorPlatformService `📊 [DB-Integrity] Data counts - Accounts: ${JSON.stringify(accountCount)}, Settings: ${JSON.stringify(settingsCount)}, Contacts: ${JSON.stringify(contactsCount)}`, ); } catch (error) { - logger.error(`❌ [DB-Integrity] Error checking data counts:`, error); + logger.warn( + `⚠️ [DB-Integrity] Error checking data counts (non-critical):`, + error, + ); } logger.log(`✅ [DB-Integrity] Database integrity check completed`); } catch (error) { + // Re-throw if it's our own error (missing tables) + if (error instanceof Error && error.message.includes("[DB-Integrity]")) { + throw error; + } + // Log other errors but don't fail init logger.error(`❌ [DB-Integrity] Database integrity check failed:`, error); } }