diff --git a/src/services/database/ConnectionPool.ts b/src/services/database/ConnectionPool.ts new file mode 100644 index 00000000..a017e4aa --- /dev/null +++ b/src/services/database/ConnectionPool.ts @@ -0,0 +1,116 @@ +import { logger } from "../../utils/logger"; +import { SQLiteDBConnection } from "@capacitor-community/sqlite"; + +interface ConnectionState { + connection: SQLiteDBConnection; + lastUsed: number; + inUse: boolean; +} + +export class DatabaseConnectionPool { + private static instance: DatabaseConnectionPool | null = null; + private connections: Map = new Map(); + private readonly MAX_CONNECTIONS = 1; // We only need one connection for SQLite + private readonly MAX_IDLE_TIME = 5 * 60 * 1000; // 5 minutes + private readonly CLEANUP_INTERVAL = 60 * 1000; // 1 minute + private cleanupInterval: NodeJS.Timeout | null = null; + + private constructor() { + // Start cleanup interval + this.cleanupInterval = setInterval(() => this.cleanup(), this.CLEANUP_INTERVAL); + } + + public static getInstance(): DatabaseConnectionPool { + if (!DatabaseConnectionPool.instance) { + DatabaseConnectionPool.instance = new DatabaseConnectionPool(); + } + return DatabaseConnectionPool.instance; + } + + public async getConnection( + dbName: string, + createConnection: () => Promise + ): Promise { + // Check if we have an existing connection + const existing = this.connections.get(dbName); + if (existing && !existing.inUse) { + existing.inUse = true; + existing.lastUsed = Date.now(); + logger.debug(`[ConnectionPool] Reusing existing connection for ${dbName}`); + return existing.connection; + } + + // If we have too many connections, wait for one to be released + if (this.connections.size >= this.MAX_CONNECTIONS) { + logger.debug(`[ConnectionPool] Waiting for connection to be released...`); + await this.waitForConnection(); + } + + // Create new connection + try { + const connection = await createConnection(); + this.connections.set(dbName, { + connection, + lastUsed: Date.now(), + inUse: true + }); + logger.debug(`[ConnectionPool] Created new connection for ${dbName}`); + return connection; + } catch (error) { + logger.error(`[ConnectionPool] Failed to create connection for ${dbName}:`, error); + throw error; + } + } + + public async releaseConnection(dbName: string): Promise { + const connection = this.connections.get(dbName); + if (connection) { + connection.inUse = false; + connection.lastUsed = Date.now(); + logger.debug(`[ConnectionPool] Released connection for ${dbName}`); + } + } + + private async waitForConnection(): Promise { + return new Promise((resolve) => { + const checkInterval = setInterval(() => { + if (this.connections.size < this.MAX_CONNECTIONS) { + clearInterval(checkInterval); + resolve(); + } + }, 100); + }); + } + + private async cleanup(): Promise { + const now = Date.now(); + for (const [dbName, state] of this.connections.entries()) { + if (!state.inUse && now - state.lastUsed > this.MAX_IDLE_TIME) { + try { + await state.connection.close(); + this.connections.delete(dbName); + logger.debug(`[ConnectionPool] Cleaned up idle connection for ${dbName}`); + } catch (error) { + logger.warn(`[ConnectionPool] Error closing idle connection for ${dbName}:`, error); + } + } + } + } + + public async closeAll(): Promise { + if (this.cleanupInterval) { + clearInterval(this.cleanupInterval); + this.cleanupInterval = null; + } + + for (const [dbName, state] of this.connections.entries()) { + try { + await state.connection.close(); + logger.debug(`[ConnectionPool] Closed connection for ${dbName}`); + } catch (error) { + logger.warn(`[ConnectionPool] Error closing connection for ${dbName}:`, error); + } + } + this.connections.clear(); + } +} \ No newline at end of file diff --git a/src/services/platforms/ElectronPlatformService.ts b/src/services/platforms/ElectronPlatformService.ts index b86d5ee4..65cbf1ba 100644 --- a/src/services/platforms/ElectronPlatformService.ts +++ b/src/services/platforms/ElectronPlatformService.ts @@ -10,6 +10,7 @@ import { SQLiteDBConnection, } from "@capacitor-community/sqlite"; import { DEFAULT_ENDORSER_API_SERVER } from "@/constants/app"; +import { DatabaseConnectionPool } from "../database/ConnectionPool"; interface Migration { name: string; @@ -25,160 +26,112 @@ interface Migration { * - System-level features (TODO) */ export class ElectronPlatformService implements PlatformService { - private sqlite: SQLiteConnection; - private db: SQLiteDBConnection | null = null; - private dbName = "timesafari.db"; - private initialized = false; + private sqlite: any; + private connection: SQLiteDBConnection | null = null; + private connectionPool: DatabaseConnectionPool; private initializationPromise: Promise | null = null; + private dbName = "timesafari"; private readonly MAX_RETRIES = 3; private readonly RETRY_DELAY = 1000; // 1 second - private dbConnectionErrorLogged = false; private dbFatalError = false; constructor() { + this.connectionPool = DatabaseConnectionPool.getInstance(); if (!window.CapacitorSQLite) { throw new Error("CapacitorSQLite not initialized in Electron"); } - this.sqlite = new SQLiteConnection(window.CapacitorSQLite); - } - - 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; - } + this.sqlite = window.CapacitorSQLite; } private async initializeDatabase(): Promise { - // If we have a fatal error, try to recover - if (this.dbFatalError) { - logger.info("Attempting to recover from fatal error state..."); - await this.resetConnection(); - } - - if (this.initialized) { + // If we already have a connection, return immediately + if (this.connection) { return; } + // If initialization is in progress, wait for it if (this.initializationPromise) { return this.initializationPromise; } + // Start initialization this.initializationPromise = (async () => { - let retryCount = 0; - let lastError: Error | null = null; - - 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"); - } + try { + if (!this.sqlite) { + logger.debug("[ElectronPlatformService] SQLite plugin not available, checking..."); + this.sqlite = await import("@capacitor-community/sqlite"); + } - // Log the connection parameters - logger.info("Calling createConnection with:", { - dbName: this.dbName, - readOnly: false, - encryption: "no-encryption", - version: 1, - useNative: true, - }); + if (!this.sqlite) { + throw new Error("SQLite plugin not available"); + } - // Create connection - this.db = await this.sqlite.createConnection( - this.dbName, // database name - false, // readOnly - "no-encryption", // encryption - 1, // version - true, // useNative - ); + // Get connection from pool + this.connection = await this.connectionPool.getConnection("timesafari", async () => { + // Create the connection + const connection = await this.sqlite.createConnection({ + database: "timesafari", + encrypted: false, + mode: "no-encryption", + readonly: false, + }); - logger.info("createConnection result:", this.db); + // Wait for the connection to be fully initialized + await new Promise((resolve, reject) => { + const checkConnection = async () => { + try { + // Try a simple query to verify the connection is ready + const result = await connection.query("SELECT 1"); + if (result && result.values) { + resolve(); + } else { + reject(new Error("Connection query returned invalid result")); + } + } catch (error) { + // If the error is that query is not a function, the connection isn't ready yet + if (error instanceof Error && error.message.includes("query is not a function")) { + setTimeout(checkConnection, 100); + } else { + reject(error); + } + } + }; + checkConnection(); + }); - if (!this.db || typeof this.db.execute !== "function") { - throw new Error("Failed to create a valid database connection"); + // Verify write access + const result = await connection.query("PRAGMA journal_mode"); + const journalMode = result.values?.[0]?.journal_mode; + if (journalMode !== "wal") { + throw new Error(`Database is not writable. Journal mode: ${journalMode}`); } - // 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", - ); - } + return connection; + }); - // 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(); - } - } - } + // Run migrations if needed + await this.runMigrations(); - // 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; + logger.info("[ElectronPlatformService] Database initialized successfully"); + } catch (error) { + logger.error("[ElectronPlatformService] Database initialization failed:", error); + this.connection = null; + throw error; + } finally { + this.initializationPromise = null; } - this.initialized = false; - this.initializationPromise = null; - throw ( - lastError || - new Error("Failed to initialize database after all retries") - ); })(); return this.initializationPromise; } private async runMigrations(): Promise { - if (!this.db) { + if (!this.connection) { throw new Error("Database not initialized"); } // Create migrations table if it doesn't exist - await this.db.execute(` + await this.connection.execute(` CREATE TABLE IF NOT EXISTS migrations ( id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL UNIQUE, @@ -187,7 +140,7 @@ export class ElectronPlatformService implements PlatformService { `); // Get list of executed migrations - const result = await this.db.query("SELECT name FROM migrations;"); + const result = await this.connection.query("SELECT name FROM migrations;"); const executedMigrations = new Set( result.values?.map((row) => row[0]) || [], ); @@ -282,8 +235,8 @@ export class ElectronPlatformService implements PlatformService { for (const migration of migrations) { if (!executedMigrations.has(migration.name)) { - await this.db.execute(migration.sql); - await this.db.run("INSERT INTO migrations (name) VALUES (?)", [ + await this.connection.execute(migration.sql); + await this.connection.run("INSERT INTO migrations (name) VALUES (?)", [ migration.name, ]); logger.log(`Migration ${migration.name} executed successfully`); @@ -400,21 +353,20 @@ export class ElectronPlatformService implements PlatformService { sql: string, params?: unknown[], ): Promise { - try { - await this.initializeDatabase(); - if (!this.db) { - throw new Error("Database not initialized"); - } - const result = await this.db.query(sql, params); - // Convert SQLite plugin result to QueryExecResult format - return { - columns: [], // SQLite plugin doesn't provide column names - values: result.values || [], - }; - } catch (error) { - logger.error("[Electron] Database query error:", error); - throw error; + if (this.dbFatalError) { + throw new Error("Database is in a fatal error state. Please restart the app."); + } + + await this.initializeDatabase(); + if (!this.connection) { + throw new Error("Database not initialized"); } + + const result = await this.connection.query(sql, params); + return { + columns: [], // SQLite plugin doesn't provide column names + values: result.values || [], + }; } /** @@ -425,89 +377,66 @@ export class ElectronPlatformService implements PlatformService { params?: unknown[], ): Promise<{ changes: number; lastId?: number }> { if (this.dbFatalError) { - throw new Error( - "Database is in a fatal error state. Please restart the app.", - ); + throw new Error("Database is in a fatal error state. Please restart the app."); } - try { - await this.initializeDatabase(); - if (!this.db) { - throw new Error("Database not initialized"); - } - const result = await this.db.run(sql, params); - // Convert SQLite plugin result to expected format - return { - changes: result.changes?.changes || 0, - lastId: result.changes?.lastId, - }; - } catch (error) { - logger.error("[Electron] Database execution error:", error); - throw error; + + await this.initializeDatabase(); + if (!this.connection) { + throw new Error("Database not initialized"); } + + const result = await this.connection.run(sql, params); + return { + changes: result.changes?.changes || 0, + lastId: result.changes?.lastId, + }; } async initialize(): Promise { - if (this.initialized) { - return; - } - - try { - await this.initializeDatabase(); - } catch (error) { - logger.error("Failed to initialize database:", error); - throw new Error( - `Database initialization failed: ${error instanceof Error ? error.message : "Unknown error"}`, - ); + if (this.dbFatalError) { + throw new Error("Database is in a fatal error state. Please restart the app."); } + await this.initializeDatabase(); } async query(sql: string, params: any[] = []): Promise { if (this.dbFatalError) { - throw new Error( - "Database is in a fatal error state. Please restart the app.", - ); + throw new Error("Database is in a fatal error state. Please restart the app."); } - if (!this.initialized) { - throw new Error("Database not initialized. Call initialize() first."); + + await this.initializeDatabase(); + if (!this.connection) { + throw new Error("Database not initialized"); } - 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[]; - }); - }); + const result = await this.connection.query(sql, params); + return (result.values || []) as T[]; } async execute(sql: string, params: any[] = []): Promise { - if (!this.initialized) { - throw new Error("Database not initialized. Call initialize() first."); + if (this.dbFatalError) { + throw new Error("Database is in a fatal error state. Please restart the app."); + } + + await this.initializeDatabase(); + if (!this.connection) { + throw new Error("Database not initialized"); } - await this.initializeDatabase().then(() => { - return this.db?.run(sql, params); - }); + await this.connection.run(sql, params); } async close(): Promise { - if (!this.initialized) { + if (!this.connection) { return; } try { - await this.db?.close(); - this.initialized = false; - this.db = null; + await this.connectionPool.releaseConnection("timesafari"); + this.connection = null; } catch (error) { logger.error("Failed to close database:", error); - throw new Error( - `Failed to close database: ${error instanceof Error ? error.message : "Unknown error"}`, - ); + throw error; } } }