forked from trent_larson/crowd-funder-for-time-pwa
- 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.
443 lines
14 KiB
TypeScript
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;
|
|
}
|
|
}
|
|
}
|