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:
@@ -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');
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user