Files
crowd-funder-for-time-pwa/src/services/platforms/ElectronPlatformService.ts
Matthew Raymer 3946a8a27a 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.
2025-06-01 03:47:20 +00:00

443 lines
14 KiB
TypeScript

import {
ImageResult,
PlatformService,
PlatformCapabilities,
} from "../PlatformService";
import { logger } from "../../utils/logger";
import { QueryExecResult } from "@/interfaces/database";
import {
SQLiteConnection,
SQLiteDBConnection,
} from "@capacitor-community/sqlite";
import { DEFAULT_ENDORSER_API_SERVER } from "@/constants/app";
import { DatabaseConnectionPool } from "../database/ConnectionPool";
interface Migration {
name: string;
sql: string;
}
/**
* Platform service implementation for Electron (desktop) platform.
* Provides native desktop functionality through Electron and Capacitor plugins for:
* - File system operations (TODO)
* - Camera integration (TODO)
* - SQLite database operations
* - System-level features (TODO)
*/
export class ElectronPlatformService implements PlatformService {
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 dbFatalError = false;
constructor() {
this.connectionPool = DatabaseConnectionPool.getInstance();
if (!window.CapacitorSQLite) {
throw new Error("CapacitorSQLite not initialized in Electron");
}
this.sqlite = window.CapacitorSQLite;
}
private async initializeDatabase(): Promise<void> {
// 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 () => {
try {
if (!this.sqlite) {
logger.debug("[ElectronPlatformService] SQLite plugin not available, checking...");
this.sqlite = await import("@capacitor-community/sqlite");
}
if (!this.sqlite) {
throw new Error("SQLite plugin not available");
}
// 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,
});
// 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();
});
// 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}`);
}
return connection;
});
// Run migrations if needed
await this.runMigrations();
logger.info("[ElectronPlatformService] Database initialized successfully");
} catch (error) {
logger.error("[ElectronPlatformService] Database initialization failed:", error);
this.connection = null;
throw error;
} finally {
this.initializationPromise = null;
}
})();
return this.initializationPromise;
}
private async runMigrations(): Promise<void> {
if (!this.connection) {
throw new Error("Database not initialized");
}
// Create migrations table if it doesn't exist
await this.connection.execute(`
CREATE TABLE IF NOT EXISTS migrations (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL UNIQUE,
executed_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
`);
// Get list of executed migrations
const result = await this.connection.query("SELECT name FROM migrations;");
const executedMigrations = new Set(
result.values?.map((row) => row[0]) || [],
);
// Run pending migrations in order
const migrations: Migration[] = [
{
name: "001_initial",
sql: `
CREATE TABLE IF NOT EXISTS accounts (
id INTEGER PRIMARY KEY AUTOINCREMENT,
dateCreated TEXT NOT NULL,
derivationPath TEXT,
did TEXT NOT NULL,
identityEncrBase64 TEXT,
mnemonicEncrBase64 TEXT,
passkeyCredIdHex TEXT,
publicKeyHex TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_accounts_did ON accounts(did);
CREATE TABLE IF NOT EXISTS secret (
id INTEGER PRIMARY KEY AUTOINCREMENT,
secretBase64 TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS settings (
id INTEGER PRIMARY KEY AUTOINCREMENT,
accountDid TEXT,
activeDid TEXT,
apiServer TEXT,
filterFeedByNearby BOOLEAN,
filterFeedByVisible BOOLEAN,
finishedOnboarding BOOLEAN,
firstName TEXT,
hideRegisterPromptOnNewContact BOOLEAN,
isRegistered BOOLEAN,
lastName TEXT,
lastAckedOfferToUserJwtId TEXT,
lastAckedOfferToUserProjectsJwtId TEXT,
lastNotifiedClaimId TEXT,
lastViewedClaimId TEXT,
notifyingNewActivityTime TEXT,
notifyingReminderMessage TEXT,
notifyingReminderTime TEXT,
partnerApiServer TEXT,
passkeyExpirationMinutes INTEGER,
profileImageUrl TEXT,
searchBoxes TEXT,
showContactGivesInline BOOLEAN,
showGeneralAdvanced BOOLEAN,
showShortcutBvc BOOLEAN,
vapid TEXT,
warnIfProdServer BOOLEAN,
warnIfTestServer BOOLEAN,
webPushServer TEXT
);
CREATE INDEX IF NOT EXISTS idx_settings_accountDid ON settings(accountDid);
INSERT INTO settings (id, apiServer) VALUES (1, '${DEFAULT_ENDORSER_API_SERVER}');
CREATE TABLE IF NOT EXISTS contacts (
id INTEGER PRIMARY KEY AUTOINCREMENT,
did TEXT NOT NULL,
name TEXT,
contactMethods TEXT,
nextPubKeyHashB64 TEXT,
notes TEXT,
profileImageUrl TEXT,
publicKeyBase64 TEXT,
seesMe BOOLEAN,
registered BOOLEAN
);
CREATE INDEX IF NOT EXISTS idx_contacts_did ON contacts(did);
CREATE INDEX IF NOT EXISTS idx_contacts_name ON contacts(name);
CREATE TABLE IF NOT EXISTS logs (
date TEXT PRIMARY KEY,
message TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS temp (
id TEXT PRIMARY KEY,
blobB64 TEXT
);
`,
},
];
for (const migration of migrations) {
if (!executedMigrations.has(migration.name)) {
await this.connection.execute(migration.sql);
await this.connection.run("INSERT INTO migrations (name) VALUES (?)", [
migration.name,
]);
logger.log(`Migration ${migration.name} executed successfully`);
}
}
}
/**
* Gets the capabilities of the Electron platform
* @returns Platform capabilities object
*/
getCapabilities(): PlatformCapabilities {
return {
hasFileSystem: false, // Not implemented yet
hasCamera: false, // Not implemented yet
isMobile: false,
isIOS: false,
hasFileDownload: false, // Not implemented yet
needsFileHandlingInstructions: false,
};
}
/**
* Reads a file from the filesystem.
* @param _path - Path to the file to read
* @returns Promise that should resolve to file contents
* @throws Error with "Not implemented" message
* @todo Implement file reading using Electron's file system API
*/
async readFile(_path: string): Promise<string> {
throw new Error("Not implemented");
}
/**
* Writes content to a file.
* @param _path - Path where to write the file
* @param _content - Content to write to the file
* @throws Error with "Not implemented" message
* @todo Implement file writing using Electron's file system API
*/
async writeFile(_path: string, _content: string): Promise<void> {
throw new Error("Not implemented");
}
/**
* Writes content to a file and opens the system share dialog.
* @param _fileName - Name of the file to create
* @param _content - Content to write to the file
* @throws Error with "Not implemented" message
* @todo Implement using Electron's dialog and file system APIs
*/
async writeAndShareFile(_fileName: string, _content: string): Promise<void> {
throw new Error("Not implemented");
}
/**
* Deletes a file from the filesystem.
* @param _path - Path to the file to delete
* @throws Error with "Not implemented" message
* @todo Implement file deletion using Electron's file system API
*/
async deleteFile(_path: string): Promise<void> {
throw new Error("Not implemented");
}
/**
* Lists files in the specified directory.
* @param _directory - Path to the directory to list
* @returns Promise that should resolve to array of filenames
* @throws Error with "Not implemented" message
* @todo Implement directory listing using Electron's file system API
*/
async listFiles(_directory: string): Promise<string[]> {
throw new Error("Not implemented");
}
/**
* Should open system camera to take a picture.
* @returns Promise that should resolve to captured image data
* @throws Error with "Not implemented" message
* @todo Implement camera access using Electron's media APIs
*/
async takePicture(): Promise<ImageResult> {
logger.error("takePicture not implemented in Electron platform");
throw new Error("Not implemented");
}
/**
* Should open system file picker for selecting an image.
* @returns Promise that should resolve to selected image data
* @throws Error with "Not implemented" message
* @todo Implement file picker using Electron's dialog API
*/
async pickImage(): Promise<ImageResult> {
logger.error("pickImage not implemented in Electron platform");
throw new Error("Not implemented");
}
/**
* Should handle deep link URLs for the desktop application.
* @param _url - The deep link URL to handle
* @throws Error with "Not implemented" message
* @todo Implement deep link handling using Electron's protocol handler
*/
async handleDeepLink(_url: string): Promise<void> {
logger.error("handleDeepLink not implemented in Electron platform");
throw new Error("Not implemented");
}
/**
* @see PlatformService.dbQuery
*/
async dbQuery(
sql: string,
params?: unknown[],
): Promise<QueryExecResult | undefined> {
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 || [],
};
}
/**
* @see PlatformService.dbExec
*/
async dbExec(
sql: string,
params?: unknown[],
): Promise<{ changes: number; lastId?: number }> {
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.run(sql, params);
return {
changes: result.changes?.changes || 0,
lastId: result.changes?.lastId,
};
}
async initialize(): Promise<void> {
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.");
}
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.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.connection.run(sql, params);
}
async close(): Promise<void> {
if (!this.connection) {
return;
}
try {
await this.connectionPool.releaseConnection("timesafari");
this.connection = null;
} catch (error) {
logger.error("Failed to close database:", error);
throw error;
}
}
}