fix(database): improve SQLite connection handling and initialization
- Add connection readiness check to ensure proper initialization - Implement retry logic for connection attempts - Fix database path handling to use consistent location - Add proper error handling for connection state - Ensure WAL journal mode for better performance - Consolidate database initialization logic The changes address several issues: - Prevent "query is not a function" errors by waiting for connection readiness - Ensure database is properly initialized before use - Maintain consistent database path across application - Improve error handling and connection state management - Add proper cleanup of database connections Technical details: - Database path: ~/.local/share/TimeSafari/timesafariSQLite.db - Journal mode: WAL (Write-Ahead Logging) - Connection options: non-encrypted, read-write mode - Tables: users, time_entries, time_goals, time_goal_entries, schema_version This commit improves database reliability and prevents connection-related errors that were occurring during application startup.
This commit is contained in:
116
src/services/database/ConnectionPool.ts
Normal file
116
src/services/database/ConnectionPool.ts
Normal file
@@ -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<string, ConnectionState> = 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<SQLiteDBConnection>
|
||||
): Promise<SQLiteDBConnection> {
|
||||
// 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<void> {
|
||||
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<void> {
|
||||
return new Promise((resolve) => {
|
||||
const checkInterval = setInterval(() => {
|
||||
if (this.connections.size < this.MAX_CONNECTIONS) {
|
||||
clearInterval(checkInterval);
|
||||
resolve();
|
||||
}
|
||||
}, 100);
|
||||
});
|
||||
}
|
||||
|
||||
private async cleanup(): Promise<void> {
|
||||
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<void> {
|
||||
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();
|
||||
}
|
||||
}
|
||||
@@ -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<void> | 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<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;
|
||||
}
|
||||
this.sqlite = window.CapacitorSQLite;
|
||||
}
|
||||
|
||||
private async initializeDatabase(): Promise<void> {
|
||||
// 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;
|
||||
try {
|
||||
if (!this.sqlite) {
|
||||
logger.debug("[ElectronPlatformService] SQLite plugin not available, checking...");
|
||||
this.sqlite = await import("@capacitor-community/sqlite");
|
||||
}
|
||||
|
||||
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");
|
||||
}
|
||||
if (!this.sqlite) {
|
||||
throw new Error("SQLite plugin not available");
|
||||
}
|
||||
|
||||
// Log the connection parameters
|
||||
logger.info("Calling createConnection with:", {
|
||||
dbName: this.dbName,
|
||||
readOnly: false,
|
||||
encryption: "no-encryption",
|
||||
version: 1,
|
||||
useNative: true,
|
||||
// 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,
|
||||
});
|
||||
|
||||
// Create connection
|
||||
this.db = await this.sqlite.createConnection(
|
||||
this.dbName, // database name
|
||||
false, // readOnly
|
||||
"no-encryption", // encryption
|
||||
1, // version
|
||||
true, // useNative
|
||||
);
|
||||
// Wait for the connection to be fully initialized
|
||||
await new Promise<void>((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();
|
||||
});
|
||||
|
||||
logger.info("createConnection result:", this.db);
|
||||
|
||||
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();
|
||||
// Run migrations if needed
|
||||
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();
|
||||
}
|
||||
}
|
||||
logger.info("[ElectronPlatformService] Database initialized successfully");
|
||||
} catch (error) {
|
||||
logger.error("[ElectronPlatformService] Database initialization failed:", error);
|
||||
this.connection = null;
|
||||
throw error;
|
||||
} finally {
|
||||
this.initializationPromise = null;
|
||||
}
|
||||
|
||||
// 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;
|
||||
}
|
||||
|
||||
private async runMigrations(): Promise<void> {
|
||||
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<QueryExecResult | undefined> {
|
||||
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<void> {
|
||||
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<T>(sql: string, params: any[] = []): Promise<T[]> {
|
||||
if (this.dbFatalError) {
|
||||
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.");
|
||||
throw new Error("Database is in a fatal error state. Please restart the app.");
|
||||
}
|
||||
|
||||
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[];
|
||||
});
|
||||
});
|
||||
await this.initializeDatabase();
|
||||
if (!this.connection) {
|
||||
throw new Error("Database not initialized");
|
||||
}
|
||||
|
||||
const result = await this.connection.query(sql, params);
|
||||
return (result.values || []) as T[];
|
||||
}
|
||||
|
||||
async execute(sql: string, params: any[] = []): Promise<void> {
|
||||
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().then(() => {
|
||||
return this.db?.run(sql, params);
|
||||
});
|
||||
await this.initializeDatabase();
|
||||
if (!this.connection) {
|
||||
throw new Error("Database not initialized");
|
||||
}
|
||||
|
||||
await this.connection.run(sql, params);
|
||||
}
|
||||
|
||||
async close(): Promise<void> {
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user