Browse Source

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
pull/134/head
Matthew Raymer 1 week ago
parent
commit
786f07e067
  1. 111
      electron/src/preload.ts
  2. 164
      electron/src/rt/sqlite-init.ts
  3. 242
      src/services/platforms/ElectronPlatformService.ts

111
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 <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');

164
electron/src/rt/sqlite-init.ts

@ -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-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;
}
});

242
src/services/platforms/ElectronPlatformService.ts

@ -42,141 +42,119 @@ export class ElectronPlatformService implements PlatformService {
this.sqlite = new SQLiteConnection(window.CapacitorSQLite);
}
private async withRetry<T>(operation: () => Promise<T>): Promise<T> {
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<void> {
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<void> {
// 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);
});
}

Loading…
Cancel
Save