Browse Source

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.
pull/134/head
Matthew Raymer 7 days ago
parent
commit
3946a8a27a
  1. 116
      src/services/database/ConnectionPool.ts
  2. 305
      src/services/platforms/ElectronPlatformService.ts

116
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<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();
}
}

305
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<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;
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<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();
});
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<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.",
);
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<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();
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<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;
}
}
}

Loading…
Cancel
Save