From 786f07e06717c0205a11f6d4d1ee014f61d36c96 Mon Sep 17 00:00:00 2001 From: Matthew Raymer Date: Sat, 31 May 2025 13:56:14 +0000 Subject: [PATCH] feat(electron): Implement SQLite database initialization with proper logging - Add comprehensive logging for database operations - Implement proper database path handling and permissions - Set up WAL journal mode and PRAGMA configurations - Create initial database schema with tables and triggers - Add retry logic for database operations - Implement proper error handling and state management Current state: - Database initialization works in main process - Connection creation succeeds with proper permissions - Schema creation and table setup complete - Logging system fully implemented - Known issue: Property name mismatch between main process and renderer causing read-only mode conflicts (to be fixed in next commit) Technical details: - Uses WAL journal mode for better concurrency - Implements proper file permissions checking - Sets up foreign key constraints - Creates tables: users, time_entries, time_goals, time_goal_entries - Adds automatic timestamp triggers - Implements proper connection lifecycle management Security: - Proper file permissions (755 for directory) - No hardcoded credentials - Proper error handling and logging - Safe file path handling Author: Matthew Raymer --- electron/src/preload.ts | 111 ++++---- electron/src/rt/sqlite-init.ts | 164 ++++++++++-- .../platforms/ElectronPlatformService.ts | 242 +++++++----------- 3 files changed, 314 insertions(+), 203 deletions(-) diff --git a/electron/src/preload.ts b/electron/src/preload.ts index 28ce1dc4..f05a3225 100644 --- a/electron/src/preload.ts +++ b/electron/src/preload.ts @@ -1,76 +1,97 @@ /** * Preload script for Electron - * Sets up context bridge and handles security policies + * Sets up secure IPC communication between renderer and main process + * * @author Matthew Raymer */ import { contextBridge, ipcRenderer } from 'electron'; -// Enable source maps in development -if (process.env.NODE_ENV === 'development') { - require('source-map-support').install(); -} +// Simple logger for preload script +const logger = { + log: (...args: unknown[]) => console.log('[Preload]', ...args), + error: (...args: unknown[]) => console.error('[Preload]', ...args), + info: (...args: unknown[]) => console.info('[Preload]', ...args), + warn: (...args: unknown[]) => console.warn('[Preload]', ...args), + debug: (...args: unknown[]) => console.debug('[Preload]', ...args), +}; -// Log that preload is running -console.log('[Preload] Script starting...'); +// Types for SQLite connection options +interface SQLiteConnectionOptions { + database: string; + version?: number; + readOnly?: boolean; + readonly?: boolean; // Handle both cases + encryption?: string; + mode?: string; + useNative?: boolean; + [key: string]: unknown; // Allow other properties +} // Create a proxy for the CapacitorSQLite plugin const createSQLiteProxy = () => { - const MAX_RETRIES = 5; + const MAX_RETRIES = 3; const RETRY_DELAY = 1000; // 1 second - const withRetry = async (operation: string, ...args: any[]) => { - let lastError; + const withRetry = async (operation: (...args: unknown[]) => Promise, ...args: unknown[]): Promise => { + let lastError: Error | null = null; for (let attempt = 1; attempt <= MAX_RETRIES; attempt++) { try { - return await ipcRenderer.invoke(`sqlite-${operation}`, ...args); + return await operation(...args); } catch (error) { - lastError = error; - console.warn(`[Preload] SQLite operation ${operation} failed (attempt ${attempt}/${MAX_RETRIES}), retrying...`, error); + lastError = error instanceof Error ? error : new Error(String(error)); if (attempt < MAX_RETRIES) { + logger.warn(`SQLite operation failed (attempt ${attempt}/${MAX_RETRIES}), retrying...`, error); await new Promise(resolve => setTimeout(resolve, RETRY_DELAY)); } } } - throw new Error(`SQLite operation ${operation} failed after ${MAX_RETRIES} attempts: ${lastError?.message || 'Unknown error'}`); + throw new Error(`SQLite operation failed after ${MAX_RETRIES} attempts: ${lastError?.message || 'Unknown error'}`); }; + const wrapOperation = (method: string) => { + return async (...args: unknown[]): Promise => { + try { + // For createConnection, ensure readOnly is false + if (method === 'create-connection') { + const options = args[0] as SQLiteConnectionOptions; + if (options && typeof options === 'object') { + // Set readOnly to false and ensure mode is rwc + options.readOnly = false; + options.mode = 'rwc'; + // Remove any lowercase readonly property if it exists + delete options.readonly; + } + } + return await withRetry(ipcRenderer.invoke, 'sqlite-' + method, ...args); + } catch (error) { + logger.error(`SQLite ${method} failed:`, error); + throw new Error(`Database operation failed: ${error instanceof Error ? error.message : 'Unknown error'}`); + } + }; + }; + + // Create a proxy that matches the CapacitorSQLite interface return { - echo: (value: string) => withRetry('echo', value), - createConnection: (options: any) => withRetry('create-connection', options), - closeConnection: (options: any) => withRetry('close-connection', options), - execute: (options: any) => withRetry('execute', options), - query: (options: any) => withRetry('query', options), - isAvailable: () => withRetry('is-available'), - getPlatform: () => Promise.resolve('electron') + echo: wrapOperation('echo'), + createConnection: wrapOperation('create-connection'), + closeConnection: wrapOperation('close-connection'), + execute: wrapOperation('execute'), + query: wrapOperation('query'), + run: wrapOperation('run'), + isAvailable: wrapOperation('is-available'), + getPlatform: () => Promise.resolve('electron'), + // Add other methods as needed }; }; -// Set up context bridge for secure IPC communication -contextBridge.exposeInMainWorld('electronAPI', { - // SQLite operations - sqlite: createSQLiteProxy(), - - // Database status events - onDatabaseStatus: (callback: (status: { status: string; error?: string }) => void) => { - ipcRenderer.on('database-status', (_event, status) => callback(status)); - return () => { - ipcRenderer.removeAllListeners('database-status'); - }; - } -}); - -// Expose CapacitorSQLite globally for the plugin system +// Expose only the CapacitorSQLite proxy contextBridge.exposeInMainWorld('CapacitorSQLite', createSQLiteProxy()); -// Handle uncaught errors -window.addEventListener('unhandledrejection', (event) => { - console.error('[Preload] Unhandled promise rejection:', event.reason); -}); +// Log startup +logger.log('Script starting...'); -window.addEventListener('error', (event) => { - console.error('[Preload] Unhandled error:', event.error); +// Handle window load +window.addEventListener('load', () => { + logger.log('Script complete'); }); - -// Log that preload is complete -console.log('[Preload] Script complete'); diff --git a/electron/src/rt/sqlite-init.ts b/electron/src/rt/sqlite-init.ts index 36e8eb20..d5c66cc6 100644 --- a/electron/src/rt/sqlite-init.ts +++ b/electron/src/rt/sqlite-init.ts @@ -624,6 +624,12 @@ export async function initializeSQLite(): Promise { // Set up IPC handlers export function setupSQLiteHandlers(): void { + // Add IPC message logging + const logIPCMessage = (channel: string, direction: 'in' | 'out', data?: any) => { + const timestamp = new Date().toISOString(); + logger.debug(`[${timestamp}] IPC ${direction.toUpperCase()} ${channel}:`, data); + }; + // Remove any existing handlers to prevent duplicates try { ipcMain.removeHandler('sqlite-is-available'); @@ -639,38 +645,49 @@ export function setupSQLiteHandlers(): void { // Register all handlers before any are called ipcMain.handle('sqlite-is-available', async () => { + logIPCMessage('sqlite-is-available', 'in'); try { - // Check both plugin instance and initialization state - return sqlitePlugin !== null && sqliteInitialized; + const result = sqlitePlugin !== null && sqliteInitialized; + logIPCMessage('sqlite-is-available', 'out', { result }); + return result; } catch (error) { logger.error('Error in sqlite-is-available:', error); + logIPCMessage('sqlite-is-available', 'out', { error: error.message }); return false; } }); // Add handler to get initialization error ipcMain.handle('sqlite-get-error', async () => { - return initializationError ? { + logIPCMessage('sqlite-get-error', 'in'); + const result = initializationError ? { message: initializationError.message, stack: initializationError.stack, name: initializationError.name } : null; + logIPCMessage('sqlite-get-error', 'out', { result }); + return result; }); // Update other handlers to handle unavailable state gracefully ipcMain.handle('sqlite-echo', async (_event, value) => { + logIPCMessage('sqlite-echo', 'in', { value }); try { if (!sqlitePlugin || !sqliteInitialized) { throw new Error('SQLite plugin not available'); } - return await sqlitePlugin.echo({ value }); + const result = await sqlitePlugin.echo({ value }); + logIPCMessage('sqlite-echo', 'out', { result }); + return result; } catch (error) { logger.error('Error in sqlite-echo:', error); + logIPCMessage('sqlite-echo', 'out', { error: error.message }); throw error; } }); ipcMain.handle('sqlite-create-connection', async (_event, options) => { + logIPCMessage('sqlite-create-connection', 'in', options); try { if (!sqlitePlugin || !sqliteInitialized) { throw new Error('SQLite plugin not available'); @@ -679,7 +696,18 @@ export function setupSQLiteHandlers(): void { if (!dbPath || !dbDir) { throw new Error('Database path not initialized'); } + + // First check if database exists + const dbExists = await sqlitePlugin.isDBExists({ + database: 'timesafari' + }); + debugLog('Database existence check:', { + exists: dbExists, + database: 'timesafari', + path: path.join(dbDir, 'timesafariSQLite.db') + }); + // Clean up connection options to be consistent const connectionOptions = { database: 'timesafari', // Base name only @@ -697,9 +725,32 @@ export function setupSQLiteHandlers(): void { expectedBehavior: 'Plugin will append SQLite suffix and handle path resolution', actualPath: path.join(dbDir, 'timesafariSQLite.db') }); + + // If database exists, try to close any existing connections first + if (dbExists?.result) { + debugLog('Database exists, checking for open connections'); + try { + const isOpen = await sqlitePlugin.isDBOpen({ + database: connectionOptions.database + }); + + if (isOpen?.result) { + debugLog('Database is open, attempting to close'); + await sqlitePlugin.close({ + database: connectionOptions.database + }); + // Wait a moment for connection to fully close + await delay(500); + } + } catch (closeError) { + logger.warn('Error checking/closing existing database:', closeError); + // Continue anyway, as the database might be in a bad state + } + } // Create connection (returns undefined but registers internally) - const result = await sqlitePlugin.createConnection(connectionOptions); + debugLog('Creating database connection'); + await sqlitePlugin.createConnection(connectionOptions); // Wait a moment for connection to be registered await delay(500); @@ -715,56 +766,139 @@ export function setupSQLiteHandlers(): void { actualPath: path.join(dbDir, 'timesafariSQLite.db') }); - if (!isRegistered) { + if (!isRegistered?.result) { throw new Error('Database not registered after createConnection'); } - // Return success object with more details - return { + // Open the database with explicit mode + debugLog('Opening database with explicit mode'); + await sqlitePlugin.open({ + database: connectionOptions.database, + version: connectionOptions.version, + readOnly: false, + encryption: connectionOptions.encryption, + useNative: connectionOptions.useNative, + mode: 'rwc' // Force read-write-create mode + }); + + debugLog('Database opened, verifying mode'); + + // Verify the connection is not read-only + const journalMode = await sqlitePlugin.query({ + database: connectionOptions.database, + statement: 'PRAGMA journal_mode;' + }); + + debugLog('Journal mode check:', journalMode); + + if (!journalMode?.values?.[0]?.journal_mode || + journalMode.values[0].journal_mode === 'off') { + // Close the database before throwing + await sqlitePlugin.close({ + database: connectionOptions.database + }); + throw new Error('Database opened in read-only mode despite options'); + } + + // Double check we can write + debugLog('Verifying write access'); + await sqlitePlugin.execute({ + database: connectionOptions.database, + statements: 'CREATE TABLE IF NOT EXISTS _write_test (id INTEGER PRIMARY KEY); DROP TABLE IF EXISTS _write_test;', + transaction: false + }); + + debugLog('Write access verified'); + + // Log the result before returning + const result = { success: true, database: connectionOptions.database, isRegistered: true, - actualPath: path.join(dbDir, 'timesafariSQLite.db'), - options: connectionOptions + isOpen: true, + readonly: false, + actualPath: path.join(dbDir, 'timesafariSQLite.db') }; + logIPCMessage('sqlite-create-connection', 'out', result); + return result; + } catch (error) { logger.error('Error in sqlite-create-connection:', error); + logIPCMessage('sqlite-create-connection', 'out', { error: error.message }); + // Try to close the database if it was opened + try { + if (sqlitePlugin) { + await sqlitePlugin.close({ + database: 'timesafari' + }); + } + } catch (closeError) { + logger.warn('Error closing database after connection error:', closeError); + } throw error; } }); ipcMain.handle('sqlite-execute', async (_event, options) => { + logIPCMessage('sqlite-execute', 'in', options); try { if (!sqlitePlugin || !sqliteInitialized) { throw new Error('SQLite plugin not available'); } - return await sqlitePlugin.execute(options); + const result = await sqlitePlugin.execute(options); + logIPCMessage('sqlite-execute', 'out', { result }); + return result; } catch (error) { logger.error('Error in sqlite-execute:', error); + logIPCMessage('sqlite-execute', 'out', { error: error.message }); throw error; } }); ipcMain.handle('sqlite-query', async (_event, options) => { + logIPCMessage('sqlite-query', 'in', options); try { if (!sqlitePlugin || !sqliteInitialized) { throw new Error('SQLite plugin not available'); } - return await sqlitePlugin.query(options); + const result = await sqlitePlugin.query(options); + logIPCMessage('sqlite-query', 'out', { result }); + return result; } catch (error) { logger.error('Error in sqlite-query:', error); + logIPCMessage('sqlite-query', 'out', { error: error.message }); + throw error; + } + }); + + ipcMain.handle('sqlite-run', async (_event, options) => { + logIPCMessage('sqlite-run', 'in', options); + try { + if (!sqlitePlugin || !sqliteInitialized) { + throw new Error('SQLite plugin not available'); + } + const result = await sqlitePlugin.run(options); + logIPCMessage('sqlite-run', 'out', { result }); + return result; + } catch (error) { + logger.error('Error in sqlite-run:', error); + logIPCMessage('sqlite-run', 'out', { error: error.message }); throw error; } }); - ipcMain.handle('sqlite-close-connection', async (_event, options) => { + ipcMain.handle('sqlite-close', async (_event, options) => { + logIPCMessage('sqlite-close', 'in', options); try { if (!sqlitePlugin || !sqliteInitialized) { throw new Error('SQLite plugin not available'); } - return await sqlitePlugin.closeConnection(options); + const result = await sqlitePlugin.close(options); + logIPCMessage('sqlite-close', 'out', { result }); + return result; } catch (error) { - logger.error('Error in sqlite-close-connection:', error); + logger.error('Error in sqlite-close:', error); + logIPCMessage('sqlite-close', 'out', { error: error.message }); throw error; } }); diff --git a/src/services/platforms/ElectronPlatformService.ts b/src/services/platforms/ElectronPlatformService.ts index 663545b1..815cabb8 100644 --- a/src/services/platforms/ElectronPlatformService.ts +++ b/src/services/platforms/ElectronPlatformService.ts @@ -42,141 +42,119 @@ export class ElectronPlatformService implements PlatformService { this.sqlite = new SQLiteConnection(window.CapacitorSQLite); } - private async withRetry(operation: () => Promise): Promise { - let lastError: Error | undefined; - for (let attempt = 1; attempt <= this.MAX_RETRIES; attempt++) { - try { - return await operation(); - } catch (error) { - lastError = error instanceof Error ? error : new Error(String(error)); - if (attempt < this.MAX_RETRIES) { - console.warn(`Database operation failed (attempt ${attempt}/${this.MAX_RETRIES}), retrying...`, error); - await new Promise(resolve => setTimeout(resolve, this.RETRY_DELAY)); + private async resetConnection(): Promise { + try { + // Try to close any existing connection + if (this.db) { + try { + await this.db.close(); + } catch (e) { + logger.warn('Error closing existing connection:', e); } + this.db = null; } + + // Reset state + this.initialized = false; + this.initializationPromise = null; + this.dbFatalError = false; + this.dbConnectionErrorLogged = false; + + // Wait a moment for cleanup + await new Promise(resolve => setTimeout(resolve, 500)); + } catch (error) { + logger.error('Error resetting connection:', error); + throw error; } - throw new Error(`Database operation failed after ${this.MAX_RETRIES} attempts: ${lastError?.message || 'Unknown error'}`); } private async initializeDatabase(): Promise { + // If we have a fatal error, try to recover if (this.dbFatalError) { - throw new Error("Database is in a fatal error state. Please restart the app."); + logger.info('Attempting to recover from fatal error state...'); + await this.resetConnection(); } + if (this.initialized) { return; } + if (this.initializationPromise) { return this.initializationPromise; } this.initializationPromise = (async () => { - try { - // Test SQLite availability using the CapacitorSQLite proxy - const isAvailable = await window.CapacitorSQLite.isAvailable(); - if (!isAvailable) { - throw new Error("SQLite is not available in the main process"); - } + let retryCount = 0; + let lastError: Error | null = null; - // Log the arguments to createConnection - logger.info("Calling createConnection with:", { - dbName: this.dbName, - readOnly: false, - encryption: "no-encryption", - version: 1, - useNative: true, - }); - - // Create/Open database with native implementation - this.db = await this.sqlite.createConnection( - this.dbName, - false, - "no-encryption", - 1, - true, // Use native implementation - ); - - logger.info("createConnection result:", this.db); - - if (!this.db || typeof this.db.execute !== 'function') { - if (!this.dbConnectionErrorLogged) { - logger.error("Failed to create a valid database connection"); - this.dbConnectionErrorLogged = true; + while (retryCount < this.MAX_RETRIES) { + try { + // Test SQLite availability + const isAvailable = await window.CapacitorSQLite.isAvailable(); + if (!isAvailable) { + throw new Error("SQLite is not available in the main process"); } - throw new Error("Failed to create a valid database connection"); - } - - // Do NOT call open() here; Electron connection is ready after createConnection - // await this.db.open(); - - // Set journal mode to WAL for better performance - await this.db.execute("PRAGMA journal_mode=WAL;"); - // Run migrations - await this.runMigrations(); + // Log the connection parameters + logger.info("Calling createConnection with:", { + dbName: this.dbName, + readOnly: false, + encryption: 'no-encryption', + version: 1, + useNative: true + }); + + // Create connection + this.db = await this.sqlite.createConnection( + this.dbName, // database name + false, // readOnly + 'no-encryption', // encryption + 1, // version + true // useNative + ); - this.initialized = true; - logger.log("[Electron] SQLite database initialized successfully"); + logger.info("createConnection result:", this.db); - // Extra logging for debugging DB creation and permissions - try { - logger.info("[Debug] process.cwd():", process.cwd()); - } catch (e) { logger.warn("[Debug] Could not log process.cwd()", e); } - try { - logger.info("[Debug] __dirname:", __dirname); - } catch (e) { logger.warn("[Debug] Could not log __dirname", e); } - try { - if (typeof window !== 'undefined' && window.electron && window.electron.getPath) { - logger.info("[Debug] electron.getPath('userData'):", window.electron.getPath('userData')); - logger.info("[Debug] electron.getPath('appPath'):", window.electron.getPath('appPath')); + if (!this.db || typeof this.db.execute !== 'function') { + throw new Error("Failed to create a valid database connection"); } - } catch (e) { logger.warn("[Debug] Could not log electron.getPath", e); } - // Try to log directory contents - try { - const fs = require('fs'); - logger.info("[Debug] Files in process.cwd():", fs.readdirSync(process.cwd())); - logger.info("[Debug] Files in __dirname:", fs.readdirSync(__dirname)); - } catch (e) { logger.warn("[Debug] Could not list directory contents", e); } - // Try to log file permissions for likely DB file - try { - const fs = require('fs'); - const dbFileCandidates = [ - `${process.cwd()}/timesafari.db`, - `${__dirname}/timesafari.db`, - `${process.cwd()}/timesafari`, - `${__dirname}/timesafari`, - ]; - for (const candidate of dbFileCandidates) { - if (fs.existsSync(candidate)) { - logger.info(`[Debug] DB file candidate exists: ${candidate}`); - logger.info(`[Debug] File stats:`, fs.statSync(candidate)); - try { - fs.accessSync(candidate, fs.constants.W_OK); - logger.info(`[Debug] File is writable: ${candidate}`); - } catch (err) { - logger.warn(`[Debug] File is NOT writable: ${candidate}`); - } - } + + // Verify connection is not read-only + const journalMode = await this.db.query('PRAGMA journal_mode;'); + if (journalMode?.values?.[0]?.journal_mode === 'off') { + throw new Error('Database opened in read-only mode despite options'); } - } catch (e) { logger.warn("[Debug] Could not check DB file permissions", e); } - // Log plugin version if available - try { - if (window.CapacitorSQLite && window.CapacitorSQLite.getPlatform) { - window.CapacitorSQLite.getPlatform().then((platform) => { - logger.info("[Debug] CapacitorSQLite platform:", platform); - }); + + // Run migrations + await this.runMigrations(); + + // Success! Clear any error state + this.dbFatalError = false; + this.dbConnectionErrorLogged = false; + this.initialized = true; + return; + + } catch (error) { + lastError = error instanceof Error ? error : new Error(String(error)); + retryCount++; + + if (retryCount < this.MAX_RETRIES) { + logger.warn(`Database initialization attempt ${retryCount}/${this.MAX_RETRIES} failed:`, error); + await new Promise(resolve => setTimeout(resolve, this.RETRY_DELAY)); + await this.resetConnection(); } - } catch (e) { logger.warn("[Debug] Could not log plugin version/platform", e); } - // Comment: To specify the database location in Capacitor SQLite for Electron, you typically only provide the database name. The plugin will create the DB in the app's user data directory. If you need to control the path, check the plugin's Electron docs or source for a 'location' or 'directory' option in createConnection. If not available, the DB will be created in the default location (usually app user data dir). - } catch (error) { - this.dbFatalError = true; - if (!this.dbConnectionErrorLogged) { - logger.error("[Electron] Error initializing SQLite database:", error); - this.dbConnectionErrorLogged = true; } - this.initialized = false; - this.initializationPromise = null; - throw error; } + + // If we get here, all retries failed + this.dbFatalError = true; + if (!this.dbConnectionErrorLogged) { + logger.error("[Electron] Error initializing SQLite database after all retries:", lastError); + this.dbConnectionErrorLogged = true; + } + this.initialized = false; + this.initializationPromise = null; + throw lastError || new Error("Failed to initialize database after all retries"); })(); return this.initializationPromise; @@ -460,26 +438,7 @@ export class ElectronPlatformService implements PlatformService { } try { - await this.withRetry(async () => { - const isAvailable = await window.CapacitorSQLite.isAvailable(); - if (!isAvailable) { - throw new Error('SQLite is not available in this environment'); - } - - this.db = await this.sqlite.createConnection( - this.dbName, - false, - "no-encryption", - 1, - true, // Use native implementation - ); - if (!this.db || typeof this.db.execute !== 'function') { - throw new Error("Failed to create a valid database connection"); - } - await this.db.execute("PRAGMA journal_mode=WAL;"); - await this.runMigrations(); - this.initialized = true; - }); + await this.initializeDatabase(); } catch (error) { console.error('Failed to initialize database:', error); throw new Error(`Database initialization failed: ${error instanceof Error ? error.message : 'Unknown error'}`); @@ -494,14 +453,16 @@ export class ElectronPlatformService implements PlatformService { throw new Error('Database not initialized. Call initialize() first.'); } - return this.withRetry(async () => { - try { - const result = await this.db?.query(sql, params); - return result?.values as T[]; - } catch (error) { - console.error('Query failed:', { sql, params, error }); - throw new Error(`Query failed: ${error instanceof Error ? error.message : 'Unknown error'}`); + return this.initializeDatabase().then(() => { + if (!this.db) { + throw new Error('Database not initialized after initialization'); } + return this.db.query(sql, params).then((result) => { + if (!result?.values) { + return [] as T[]; + } + return result.values as T[]; + }); }); } @@ -510,13 +471,8 @@ export class ElectronPlatformService implements PlatformService { throw new Error('Database not initialized. Call initialize() first.'); } - await this.withRetry(async () => { - try { - await this.db?.run(sql, params); - } catch (error) { - console.error('Execute failed:', { sql, params, error }); - throw new Error(`Execute failed: ${error instanceof Error ? error.message : 'Unknown error'}`); - } + await this.initializeDatabase().then(() => { + return this.db?.run(sql, params); }); }