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
This commit is contained in:
Matthew Raymer
2025-05-31 13:56:14 +00:00
parent 710cc1683c
commit 786f07e067
3 changed files with 322 additions and 211 deletions

View File

@@ -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 <T>(operation: (...args: unknown[]) => Promise<T>, ...args: unknown[]): Promise<T> => {
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<unknown> => {
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');

View File

@@ -624,6 +624,12 @@ export async function initializeSQLite(): Promise<void> {
// 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-close-connection', async (_event, options) => {
ipcMain.handle('sqlite-run', async (_event, options) => {
logIPCMessage('sqlite-run', 'in', options);
try {
if (!sqlitePlugin || !sqliteInitialized) {
throw new Error('SQLite plugin not available');
}
return await sqlitePlugin.closeConnection(options);
const result = await sqlitePlugin.run(options);
logIPCMessage('sqlite-run', 'out', { result });
return result;
} catch (error) {
logger.error('Error in sqlite-close-connection:', error);
logger.error('Error in sqlite-run:', error);
logIPCMessage('sqlite-run', 'out', { error: error.message });
throw error;
}
});
ipcMain.handle('sqlite-close', async (_event, options) => {
logIPCMessage('sqlite-close', 'in', options);
try {
if (!sqlitePlugin || !sqliteInitialized) {
throw new Error('SQLite plugin not available');
}
const result = await sqlitePlugin.close(options);
logIPCMessage('sqlite-close', 'out', { result });
return result;
} catch (error) {
logger.error('Error in sqlite-close:', error);
logIPCMessage('sqlite-close', 'out', { error: error.message });
throw error;
}
});