/** * SQLite Initialization and Management for TimeSafari Electron * * This module handles the complete lifecycle of SQLite database initialization, * connection management, and IPC communication in the TimeSafari Electron app. * * Key Features: * - Database path management with proper permissions * - Plugin initialization and state verification * - Connection lifecycle management * - PRAGMA configuration for optimal performance * - Migration system integration with configurable logging * - Error handling and recovery * - IPC communication layer with proper cleanup * * Database Configuration: * - Uses WAL journal mode for better concurrency * - Configures optimal PRAGMA settings * - Implements connection pooling * - Handles encryption (when enabled) * * State Management: * - Tracks plugin initialization state * - Monitors connection health * - Manages transaction state * - Implements recovery mechanisms * * Error Handling: * - Custom SQLiteError class for detailed error tracking * - Comprehensive error logging * - Automatic recovery attempts * - State verification before operations * * Security: * - Proper file permissions (0o755) * - Write access verification * - Connection state validation * - Transaction safety * * Performance: * - WAL mode for better concurrency * - Optimized PRAGMA settings * - Connection pooling * - Efficient state management * * @author Matthew Raymer * @version 1.0.0 * @since 2025-06-01 */ import { app, ipcMain } from 'electron'; import { CapacitorSQLite } from '@capacitor-community/sqlite/electron/dist/plugin.js'; import * as SQLiteModule from '@capacitor-community/sqlite/electron/dist/plugin.js'; import fs from 'fs'; import path from 'path'; import os from 'os'; import { runMigrations } from './sqlite-migrations'; import { logger } from './logger'; import { startDatabaseOperation, endDatabaseOperation } from '../setup'; // Types for state management interface PluginState { isInitialized: boolean; isAvailable: boolean; lastVerified: Date | null; lastError: Error | null; instance: any | null; } interface TransactionState { isActive: boolean; lastVerified: Date | null; database: string | null; } // State tracking let pluginState: PluginState = { isInitialized: false, isAvailable: false, lastVerified: null, lastError: null, instance: null }; let transactionState: TransactionState = { isActive: false, lastVerified: null, database: null }; // Constants const MAX_RECOVERY_ATTEMPTS = 3; const RECOVERY_DELAY_MS = 1000; const VERIFICATION_TIMEOUT_MS = 5000; // Type definitions for SQLite operations interface SQLiteConnectionOptions { database: string; version?: number; readOnly?: boolean; readonly?: boolean; mode?: string; useNative?: boolean; [key: string]: unknown; } interface SQLiteQueryOptions { statement: string; values?: unknown[]; } interface SQLiteExecuteOptions { statements: SQLiteQueryOptions[]; transaction?: boolean; } interface SQLiteResult { changes?: { changes: number; lastId?: number }; values?: Record[]; } interface SQLiteEchoResult { value: string; } // Enhanced error types class SQLiteError extends Error { constructor( message: string, public context: string, public code: string = 'SQLITE_ERROR', public originalError?: unknown, public details?: Record ) { super(message); this.name = 'SQLiteError'; } toJSON() { return { name: this.name, message: this.message, context: this.context, code: this.code, details: this.details, stack: this.stack, originalError: this.originalError instanceof Error ? { name: this.originalError.name, message: this.originalError.message, stack: this.originalError.stack } : this.originalError }; } } // Validation utilities const validateConnectionOptions = (options: unknown): SQLiteConnectionOptions => { if (!options || typeof options !== 'object') { throw new SQLiteError('Invalid connection options', 'validation', 'SQLITE_INVALID_OPTIONS'); } const opts = options as SQLiteConnectionOptions; if (!opts.database || typeof opts.database !== 'string') { throw new SQLiteError('Database name is required', 'validation', 'SQLITE_INVALID_DATABASE'); } return opts; }; const validateQueryOptions = (options: unknown): SQLiteQueryOptions => { if (!options || typeof options !== 'object') { throw new SQLiteError('Invalid query options', 'validation', 'SQLITE_INVALID_OPTIONS'); } const opts = options as SQLiteQueryOptions; if (!opts.statement || typeof opts.statement !== 'string') { throw new SQLiteError('SQL statement is required', 'validation', 'SQLITE_INVALID_STATEMENT'); } if (opts.values && !Array.isArray(opts.values)) { throw new SQLiteError('Values must be an array', 'validation', 'SQLITE_INVALID_VALUES'); } return opts; }; // Enhanced error handler with more context const handleError = (error: unknown, context: string, details?: Record): SQLiteError => { const errorMessage = error instanceof Error ? error.message : 'Unknown error occurred'; const errorStack = error instanceof Error ? error.stack : undefined; // Determine error code based on context and message let code = 'SQLITE_ERROR'; if (errorMessage.includes('database is locked')) { code = 'SQLITE_BUSY'; } else if (errorMessage.includes('no such table')) { code = 'SQLITE_NO_TABLE'; } else if (errorMessage.includes('syntax error')) { code = 'SQLITE_SYNTAX_ERROR'; } logger.error(`Error in ${context}:`, { message: errorMessage, stack: errorStack, context, code, details, timestamp: new Date().toISOString() }); return new SQLiteError( `${context} failed: ${errorMessage}`, context, code, error, details ); }; // Add delay utility with timeout const delay = (ms: number, timeoutMs: number = VERIFICATION_TIMEOUT_MS): Promise => { return new Promise((resolve, reject) => { const timeout = setTimeout(() => { reject(new SQLiteError('Operation timed out', 'delay')); }, timeoutMs); setTimeout(() => { clearTimeout(timeout); resolve(); }, ms); }); }; // Plugin state verification const verifyPluginState = async (): Promise => { if (!pluginState.instance || !pluginState.isInitialized) { return false; } try { // Test plugin responsiveness const echoResult = await pluginState.instance.echo({ value: 'test' }); if (!echoResult || echoResult.value !== 'test') { throw new SQLiteError('Plugin echo test failed', 'verifyPluginState'); } pluginState.isAvailable = true; pluginState.lastVerified = new Date(); pluginState.lastError = null; return true; } catch (error) { pluginState.isAvailable = false; pluginState.lastError = handleError(error, 'verifyPluginState'); return false; } }; // Transaction state verification const verifyTransactionState = async (database: string): Promise => { if (!pluginState.instance || !pluginState.isAvailable) { return false; } try { // Check if we're in a transaction const isActive = await pluginState.instance.isTransactionActive({ database }); transactionState.isActive = isActive; transactionState.lastVerified = new Date(); transactionState.database = database; return true; } catch (error) { transactionState.isActive = false; transactionState.lastVerified = new Date(); transactionState.database = null; logger.error('Transaction state verification failed:', error); return false; } }; // Plugin initialization const initializePlugin = async (): Promise => { logger.info('Starting plugin initialization'); try { // Create plugin instance let rawPlugin; if (SQLiteModule.default?.CapacitorSQLite) { logger.debug('Using default export CapacitorSQLite'); rawPlugin = new SQLiteModule.default.CapacitorSQLite(); } else { logger.debug('Using direct CapacitorSQLite class'); rawPlugin = new CapacitorSQLite(); } // Verify instance if (!rawPlugin || typeof rawPlugin !== 'object') { throw new SQLiteError('Invalid plugin instance created', 'initializePlugin'); } // Test plugin functionality const echoResult = await rawPlugin.echo({ value: 'test' }); if (!echoResult || echoResult.value !== 'test') { throw new SQLiteError('Plugin echo test failed', 'initializePlugin'); } // Update state only after successful verification pluginState = { isInitialized: true, isAvailable: true, lastVerified: new Date(), lastError: null, instance: rawPlugin }; logger.info('Plugin initialized successfully'); return true; } catch (error) { pluginState = { isInitialized: false, isAvailable: false, lastVerified: new Date(), lastError: handleError(error, 'initializePlugin'), instance: null }; logger.error('Plugin initialization failed:', { error: pluginState.lastError, timestamp: new Date().toISOString() }); return false; } }; // Recovery mechanism const recoverPluginState = async (attempt: number = 1): Promise => { logger.info(`Attempting plugin state recovery (attempt ${attempt}/${MAX_RECOVERY_ATTEMPTS})`); if (attempt > MAX_RECOVERY_ATTEMPTS) { logger.error('Max recovery attempts reached'); return false; } try { // Cleanup existing connection if any if (pluginState.instance) { try { await pluginState.instance.closeConnection({ database: 'timesafari' }); logger.debug('Closed existing database connection during recovery'); } catch (error) { logger.warn('Error closing connection during recovery:', error); } } // Reset state pluginState = { isInitialized: false, isAvailable: false, lastVerified: new Date(), lastError: null, instance: null }; // Wait before retry with exponential backoff const backoffDelay = RECOVERY_DELAY_MS * Math.pow(2, attempt - 1); await delay(backoffDelay); // Reinitialize const success = await initializePlugin(); if (!success && attempt < MAX_RECOVERY_ATTEMPTS) { return recoverPluginState(attempt + 1); } return success; } catch (error) { logger.error('Plugin recovery failed:', error); if (attempt < MAX_RECOVERY_ATTEMPTS) { return recoverPluginState(attempt + 1); } return false; } }; /** * Initializes database paths and ensures proper permissions * * This function: * 1. Creates the database directory if it doesn't exist * 2. Sets proper permissions (0o755) * 3. Verifies write access * 4. Returns the absolute path to the database directory * * @returns {Promise} Absolute path to database directory * @throws {SQLiteError} If directory creation or permission setting fails */ const initializeDatabasePaths = async (): Promise => { try { // Get the absolute app data directory const appDataDir = path.join(os.homedir(), 'Databases', 'TimeSafari'); logger.info('App data directory:', appDataDir); // Ensure directory exists with proper permissions if (!fs.existsSync(appDataDir)) { await fs.promises.mkdir(appDataDir, { recursive: true, mode: 0o755 }); } else { await fs.promises.chmod(appDataDir, 0o755); } // Verify directory permissions const stats = await fs.promises.stat(appDataDir); logger.info('Directory permissions:', { mode: stats.mode.toString(8), uid: stats.uid, gid: stats.gid, isDirectory: stats.isDirectory(), isWritable: !!(stats.mode & 0o200) }); // Test write access const testFile = path.join(appDataDir, '.write-test'); await fs.promises.writeFile(testFile, 'test'); await fs.promises.unlink(testFile); return appDataDir; } catch (error) { throw handleError(error, 'initializeDatabasePaths'); } }; /** * Main SQLite initialization function * * Orchestrates the complete database initialization process: * 1. Sets up database paths * 2. Initializes the SQLite plugin * 3. Creates and verifies database connection * 4. Configures database PRAGMAs * 5. Runs database migrations * 6. Handles errors and recovery * * Database Configuration: * - Uses WAL journal mode * - Enables foreign keys * - Sets optimal page size and cache * - Configures busy timeout * * Error Recovery: * - Implements exponential backoff * - Verifies plugin state * - Attempts connection recovery * - Maintains detailed error logs * * @throws {SQLiteError} If initialization fails and recovery is unsuccessful */ export async function initializeSQLite(): Promise { logger.info('Starting SQLite initialization'); try { // Initialize database paths const dbDir = await initializeDatabasePaths(); const dbPath = path.join(dbDir, 'timesafariSQLite.db'); // Initialize plugin if (!await initializePlugin()) { throw new SQLiteError('Plugin initialization failed', 'initializeSQLite'); } // Verify plugin state if (!await verifyPluginState()) { throw new SQLiteError('Plugin state verification failed', 'initializeSQLite'); } // Set up database connection - simplified to match sacred-sql const connectionOptions = { database: 'timesafari', version: 1, readOnly: false, useNative: true, mode: 'rwc' }; // Create and verify connection logger.debug('Creating database connection:', connectionOptions); await pluginState.instance.createConnection(connectionOptions); await delay(500); // Wait for connection registration const isRegistered = await pluginState.instance.isDatabase({ database: connectionOptions.database }); if (!isRegistered) { throw new SQLiteError('Database not registered', 'initializeSQLite'); } // Open database logger.debug('Opening database with options:', connectionOptions); await pluginState.instance.open({ ...connectionOptions, mode: 'rwc' }); // Set PRAGMAs to match sacred-sql approach const pragmaStatements = [ 'PRAGMA journal_mode = MEMORY;', // Changed to MEMORY mode to match sacred-sql 'PRAGMA foreign_keys = ON;', 'PRAGMA synchronous = NORMAL;', 'PRAGMA temp_store = MEMORY;', 'PRAGMA page_size = 4096;', 'PRAGMA cache_size = 2000;' // Removed WAL-specific settings ]; logger.debug('Setting database PRAGMAs'); for (const statement of pragmaStatements) { try { logger.debug('Executing PRAGMA:', statement); const result = await pluginState.instance.execute({ database: connectionOptions.database, statements: statement, transaction: false }); logger.debug('PRAGMA result:', { statement, result }); } catch (error) { logger.error('PRAGMA execution failed:', { statement, error: error instanceof Error ? { message: error.message, stack: error.stack, name: error.name } : error }); throw error; } } // Run migrations with enhanced error logging logger.info('Starting database migrations'); const migrationResults = await runMigrations( pluginState.instance, connectionOptions.database ); // Check migration results with detailed logging const failedMigrations = migrationResults.filter(r => !r.success); if (failedMigrations.length > 0) { logger.error('Migration failures:', { totalMigrations: migrationResults.length, failedCount: failedMigrations.length, failures: failedMigrations.map(f => ({ version: f.version, name: f.name, error: f.error instanceof Error ? { message: f.error.message, stack: f.error.stack, name: f.error.name } : f.error, state: f.state })) }); throw new SQLiteError( `Database migrations failed: ${failedMigrations.map(f => f.name).join(', ')}`, 'initializeSQLite', 'SQLITE_MIGRATION_FAILED', failedMigrations ); } logger.info('SQLite initialization completed successfully'); } catch (error) { const sqliteError = handleError(error, 'initializeSQLite'); logger.error('SQLite initialization failed:', { error: sqliteError, pluginState: { isInitialized: pluginState.isInitialized, isAvailable: pluginState.isAvailable, lastVerified: pluginState.lastVerified, lastError: pluginState.lastError } }); // Attempt recovery if (await recoverPluginState()) { logger.info('Recovery successful, retrying initialization'); return initializeSQLite(); } throw sqliteError; } } // Add IPC handler tracking const registeredHandlers = new Set(); /** * Removes all registered SQLite IPC handlers * Called before re-registering handlers to prevent duplicates */ const cleanupSQLiteHandlers = (): void => { logger.debug('Cleaning up SQLite IPC handlers'); for (const channel of registeredHandlers) { try { ipcMain.removeHandler(channel); logger.debug(`Removed handler for channel: ${channel}`); } catch (error) { logger.warn(`Failed to remove handler for channel ${channel}:`, error); } } registeredHandlers.clear(); }; /** * Registers an IPC handler with tracking * @param channel The IPC channel to register * @param handler The handler function */ const registerHandler = (channel: string, handler: (...args: any[]) => Promise): void => { if (registeredHandlers.has(channel)) { logger.debug(`Handler already registered for channel: ${channel}, removing first`); ipcMain.removeHandler(channel); } ipcMain.handle(channel, handler); registeredHandlers.add(channel); logger.debug(`Registered handler for channel: ${channel}`); }; /** * Sets up IPC handlers for SQLite operations * Registers handlers for all SQLite operations defined in VALID_CHANNELS.invoke * Tracks database operations to prevent reloads during critical operations * Implements proper handler cleanup to prevent duplicates * * @author Matthew Raymer */ export function setupSQLiteHandlers(): void { logger.info('Setting up SQLite IPC handlers'); // Clean up any existing handlers first cleanupSQLiteHandlers(); // Handler for checking SQLite availability registerHandler('sqlite-is-available', async () => { logger.debug('Checking SQLite availability'); try { startDatabaseOperation(); const isAvailable = await verifyPluginState(); logger.debug('SQLite availability check:', { isAvailable }); return isAvailable; } catch (error) { logger.error('SQLite availability check failed:', error); throw error; } finally { endDatabaseOperation(); } }); // Handler for echo test registerHandler('sqlite-echo', async (_event, { value }) => { logger.debug('SQLite echo test:', { value }); try { startDatabaseOperation(); if (!pluginState.instance) { throw new SQLiteError('Plugin not initialized', 'sqlite-echo'); } const result = await pluginState.instance.echo({ value }); logger.debug('SQLite echo result:', result); return result; } catch (error) { logger.error('SQLite echo failed:', error); throw error; } finally { endDatabaseOperation(); } }); // Handler for creating database connection registerHandler('sqlite-create-connection', async (_event, options: SQLiteConnectionOptions) => { logger.debug('Creating SQLite connection:', options); try { startDatabaseOperation(); if (!pluginState.instance) { throw new SQLiteError('Plugin not initialized', 'sqlite-create-connection'); } const validatedOptions = validateConnectionOptions(options); await pluginState.instance.createConnection(validatedOptions); logger.debug('SQLite connection created successfully'); return true; } catch (error) { logger.error('SQLite connection creation failed:', error); throw error; } finally { endDatabaseOperation(); } }); // Handler for executing SQL statements registerHandler('sqlite-execute', async (_event, options: SQLiteExecuteOptions) => { logger.debug('Executing SQL statements:', options); try { startDatabaseOperation(); if (!pluginState.instance) { throw new SQLiteError('Plugin not initialized', 'sqlite-execute'); } const result = await pluginState.instance.execute(options); logger.debug('SQL execution result:', result); return result; } catch (error) { logger.error('SQL execution failed:', error); throw error; } finally { endDatabaseOperation(); } }); // Handler for querying data registerHandler('sqlite-query', async (_event, options: SQLiteQueryOptions) => { logger.debug('Querying SQLite:', options); try { startDatabaseOperation(); if (!pluginState.instance) { throw new SQLiteError('Plugin not initialized', 'sqlite-query'); } const result = await pluginState.instance.query(options); logger.debug('SQL query result:', result); return result; } catch (error) { logger.error('SQL query failed:', error); throw error; } finally { endDatabaseOperation(); } }); // Handler for running SQL statements registerHandler('sqlite-run', async (_event, options: SQLiteQueryOptions) => { logger.debug('Running SQL statement:', options); try { startDatabaseOperation(); if (!pluginState.instance) { throw new SQLiteError('Plugin not initialized', 'sqlite-run'); } const result = await pluginState.instance.run(options); logger.debug('SQL run result:', result); return result; } catch (error) { logger.error('SQL run failed:', error); throw error; } finally { endDatabaseOperation(); } }); // Handler for closing database connection registerHandler('sqlite-close-connection', async (_event, options: { database: string }) => { logger.debug('Closing SQLite connection:', options); try { startDatabaseOperation(); if (!pluginState.instance) { throw new SQLiteError('Plugin not initialized', 'sqlite-close-connection'); } await pluginState.instance.closeConnection(options); logger.debug('SQLite connection closed successfully'); return true; } catch (error) { logger.error('SQLite connection close failed:', error); throw error; } finally { endDatabaseOperation(); } }); // Handler for opening database registerHandler('sqlite-open', async (_event, options: SQLiteConnectionOptions) => { logger.debug('Opening SQLite database:', options); try { startDatabaseOperation(); if (!pluginState.instance) { throw new SQLiteError('Plugin not initialized', 'sqlite-open'); } await pluginState.instance.open(options); logger.debug('SQLite database opened successfully'); return true; } catch (error) { logger.error('SQLite database open failed:', error); throw error; } finally { endDatabaseOperation(); } }); // Handler for closing database registerHandler('sqlite-close', async (_event, options: { database: string }) => { logger.debug('Closing SQLite database:', options); try { startDatabaseOperation(); if (!pluginState.instance) { throw new SQLiteError('Plugin not initialized', 'sqlite-close'); } await pluginState.instance.close(options); logger.debug('SQLite database closed successfully'); return true; } catch (error) { logger.error('SQLite database close failed:', error); throw error; } finally { endDatabaseOperation(); } }); // Handler for checking if database is open registerHandler('sqlite-is-db-open', async (_event, options: { database: string }) => { logger.debug('Checking if SQLite database is open:', options); try { startDatabaseOperation(); if (!pluginState.instance) { throw new SQLiteError('Plugin not initialized', 'sqlite-is-db-open'); } const isOpen = await pluginState.instance.isDBOpen(options); logger.debug('SQLite database open check:', { isOpen }); return isOpen; } catch (error) { logger.error('SQLite database open check failed:', error); throw error; } finally { endDatabaseOperation(); } }); // Handler for getting database path registerHandler('get-path', async () => { logger.debug('Getting database path'); try { startDatabaseOperation(); const dbDir = await initializeDatabasePaths(); const dbPath = path.join(dbDir, 'timesafariSQLite.db'); logger.debug('Database path:', dbPath); return dbPath; } catch (error) { logger.error('Failed to get database path:', error); throw error; } finally { endDatabaseOperation(); } }); // Handler for getting base path registerHandler('get-base-path', async () => { logger.debug('Getting base path'); try { startDatabaseOperation(); const dbDir = await initializeDatabasePaths(); logger.debug('Base path:', dbDir); return dbDir; } catch (error) { logger.error('Failed to get base path:', error); throw error; } finally { endDatabaseOperation(); } }); // Handler for SQLite status updates registerHandler('sqlite-status', async (_event, status: { status: string; database: string; timestamp: number }) => { logger.debug('SQLite status update:', status); try { startDatabaseOperation(); if (!pluginState.instance) { throw new SQLiteError('Plugin not initialized', 'sqlite-status'); } // Verify database is still open const isOpen = await pluginState.instance.isDBOpen({ database: status.database }); if (!isOpen) { throw new SQLiteError('Database not open', 'sqlite-status'); } logger.info('SQLite status update processed:', { status: status.status, database: status.database, timestamp: new Date(status.timestamp).toISOString() }); return { success: true, isOpen }; } catch (error) { logger.error('SQLite status update failed:', error); throw error; } finally { endDatabaseOperation(); } }); logger.info('SQLite IPC handlers setup complete'); }