diff --git a/src/services/PlatformServiceFactory.ts b/src/services/PlatformServiceFactory.ts index 5ee68f0e..e938ee61 100644 --- a/src/services/PlatformServiceFactory.ts +++ b/src/services/PlatformServiceFactory.ts @@ -4,76 +4,181 @@ import { CapacitorPlatformService } from "./platforms/CapacitorPlatformService"; import { ElectronPlatformService } from "./platforms/ElectronPlatformService"; /** - * Factory class for creating platform-specific service implementations. - * Implements the Singleton pattern to ensure only one instance of PlatformService exists. + * HMR-safe global singleton storage for PlatformService * - * The factory determines which platform implementation to use based on the VITE_PLATFORM - * environment variable. Supported platforms are: - * - capacitor: Mobile platform using Capacitor - * - electron: Desktop platform using Electron with Capacitor - * - web: Default web platform (fallback) + * Uses multiple fallbacks to ensure persistence across module reloads: + * 1. globalThis (standard, works in most environments) + * 2. window (browser fallback) + * 3. self (web worker fallback) + */ +declare global { + // eslint-disable-next-line no-var + var __PLATFORM_SERVICE_SINGLETON__: PlatformService | undefined; +} + +/** + * Get the global object for singleton storage + * Uses multiple fallbacks to ensure compatibility + */ +function getGlobal(): typeof globalThis { + if (typeof globalThis !== "undefined") return globalThis; + if (typeof window !== "undefined") return window as typeof globalThis; + if (typeof self !== "undefined") return self as typeof globalThis; + // Fallback for Node.js environments + // eslint-disable-next-line @typescript-eslint/no-explicit-any + return {} as any; +} + +/** + * Factory function to create platform-specific service implementation + * + * Uses console.log instead of logger to avoid circular dependency + * (logger imports PlatformServiceFactory) + */ +function create(): PlatformService { + const which = import.meta.env?.VITE_PLATFORM ?? "web"; + + // Use console.log to avoid circular dependency with logger + // eslint-disable-next-line no-console + console.log( + `[PlatformServiceFactory] Creating singleton instance for platform: ${which}`, + ); + + if (which === "capacitor") return new CapacitorPlatformService(); + if (which === "electron") return new ElectronPlatformService(); + return new WebPlatformService(); +} + +/** + * Get or create the HMR-safe singleton instance of PlatformService + * + * Uses lazy initialization to avoid circular dependency issues at module load time. + */ +function getPlatformSvc(): PlatformService { + const global = getGlobal(); + + // Debug: Check if singleton exists (always log in dev to diagnose) + const exists = global.__PLATFORM_SERVICE_SINGLETON__ !== undefined; + const hasKey = Object.prototype.hasOwnProperty.call( + global, + "__PLATFORM_SERVICE_SINGLETON__", + ); + // eslint-disable-next-line no-console + console.log( + `[PlatformServiceFactory] Singleton check: ${exists ? "EXISTS" : "MISSING"} (global type: ${typeof global}, has key: ${hasKey})`, + ); + + if (!exists) { + // eslint-disable-next-line no-console + console.log("[PlatformServiceFactory] Creating new singleton instance"); + global.__PLATFORM_SERVICE_SINGLETON__ = create(); + // Verify it was stored + if (global.__PLATFORM_SERVICE_SINGLETON__) { + // eslint-disable-next-line no-console + console.log( + "[PlatformServiceFactory] Singleton created and stored successfully", + ); + } else { + // eslint-disable-next-line no-console + console.error( + "[PlatformServiceFactory] ERROR: Singleton creation failed - storage returned undefined", + ); + } + } else { + // Log singleton reuse + // eslint-disable-next-line no-console + console.log("[PlatformServiceFactory] Reusing existing singleton instance"); + } + + // Type guard: ensure singleton exists (should never be undefined at this point) + const singleton = global.__PLATFORM_SERVICE_SINGLETON__; + if (!singleton) { + // eslint-disable-next-line no-console + console.error( + "[PlatformServiceFactory] CRITICAL: Singleton is undefined after creation/retrieval", + ); + // Fallback: create a new one + global.__PLATFORM_SERVICE_SINGLETON__ = create(); + return global.__PLATFORM_SERVICE_SINGLETON__; + } + + return singleton; +} + +/** + * HMR-safe singleton instance of PlatformService + * + * This is the ONLY way to access PlatformService throughout the application. + * Do not create new instances of platform services directly. + * + * Uses lazy initialization via Proxy to avoid circular dependency issues at module load time. * * @example * ```typescript - * const platformService = PlatformServiceFactory.getInstance(); - * await platformService.takePicture(); + * import { PlatformSvc } from "./services/PlatformServiceFactory"; + * await PlatformSvc.takePicture(); * ``` */ -export class PlatformServiceFactory { - private static instance: PlatformService | null = null; - private static callCount = 0; // Debug counter - private static creationLogged = false; // Only log creation once +export const PlatformSvc = new Proxy({} as PlatformService, { + get(_target, prop) { + const svc = getPlatformSvc(); + const value = (svc as unknown as Record)[prop as string]; + // Bind methods to maintain 'this' context + if (typeof value === "function") { + return value.bind(svc); + } + return value; + }, +}); +// Preserve singleton across Vite HMR +if (import.meta?.hot) { + if (import.meta.env?.MODE === "development") { + // Defer logging until singleton is actually accessed + setTimeout(() => { + const global = getGlobal(); + if (global.__PLATFORM_SERVICE_SINGLETON__) { + // Use console.log to avoid circular dependency with logger + // eslint-disable-next-line no-console + console.log("[PlatformSvc] HMR active; singleton pinned to global"); + } + }, 0); + } + import.meta.hot.accept(() => { + // Don't recreate on HMR - keep existing instance + const global = getGlobal(); + if (!global.__PLATFORM_SERVICE_SINGLETON__) { + // Restore singleton if it was lost during HMR + global.__PLATFORM_SERVICE_SINGLETON__ = getPlatformSvc(); + } + }); + import.meta.hot.dispose(() => { + // Don't delete - keep the global instance + // The singleton will persist in globalThis/window/self + }); +} + +/** + * Legacy factory class for backward compatibility + * @deprecated Use `PlatformSvc` directly instead + */ +export class PlatformServiceFactory { /** - * Gets or creates the singleton instance of PlatformService. - * Creates the appropriate platform-specific implementation based on environment. - * - * @returns {PlatformService} The singleton instance of PlatformService + * Gets the singleton instance of PlatformService. + * @deprecated Use `PlatformSvc` directly instead */ public static getInstance(): PlatformService { - PlatformServiceFactory.callCount++; - - if (PlatformServiceFactory.instance) { - // Normal case - return existing instance silently - return PlatformServiceFactory.instance; - } - - // Only log when actually creating the instance - const platform = process.env.VITE_PLATFORM || "web"; - - if (!PlatformServiceFactory.creationLogged) { - // Use console for critical startup message to avoid circular dependency - // eslint-disable-next-line no-console - console.log( - `[PlatformServiceFactory] Creating singleton instance for platform: ${platform}`, - ); - PlatformServiceFactory.creationLogged = true; - } - - switch (platform) { - case "capacitor": - PlatformServiceFactory.instance = new CapacitorPlatformService(); - break; - case "electron": - // Use a specialized electron service that extends CapacitorPlatformService - PlatformServiceFactory.instance = new ElectronPlatformService(); - break; - case "web": - default: - PlatformServiceFactory.instance = new WebPlatformService(); - break; - } - - return PlatformServiceFactory.instance; + return PlatformSvc; } /** * Debug method to check singleton usage stats */ public static getStats(): { callCount: number; instanceExists: boolean } { + const global = getGlobal(); return { - callCount: PlatformServiceFactory.callCount, - instanceExists: PlatformServiceFactory.instance !== null, + callCount: 0, // Deprecated - no longer tracking + instanceExists: global.__PLATFORM_SERVICE_SINGLETON__ !== undefined, }; } } diff --git a/src/services/platforms/CapacitorPlatformService.ts b/src/services/platforms/CapacitorPlatformService.ts index 88e3548d..f7e0784a 100644 --- a/src/services/platforms/CapacitorPlatformService.ts +++ b/src/services/platforms/CapacitorPlatformService.ts @@ -8,11 +8,10 @@ import { import { Capacitor } from "@capacitor/core"; import { Share } from "@capacitor/share"; import { - SQLiteConnection, SQLiteDBConnection, - CapacitorSQLite, DBSQLiteValues, } from "@capacitor-community/sqlite"; +import { SQLITE } from "./sqlite"; import { runMigrations } from "@/db-sql/migration"; import { QueryExecResult } from "@/interfaces/database"; @@ -41,16 +40,23 @@ export class CapacitorPlatformService /** Current camera direction */ private currentDirection: CameraDirection = CameraDirection.Rear; - private sqlite: SQLiteConnection; + // Connection parameters - must be 100% consistent across all plugin calls + private static readonly DB_NAME = "timesafari.sqlite"; + private static readonly READ_ONLY = false; + private static readonly VERSION = 1; + private static readonly ENCRYPTED = false; + private static readonly MODE = "no-encryption"; + private static readonly LOG_CONN = + import.meta.env?.VITE_LOG_DB_CONN === "true"; + private db: SQLiteDBConnection | null = null; - private dbName = "timesafari.sqlite"; private initialized = false; private initializationPromise: Promise | null = null; private operationQueue = new OperationQueue(); constructor() { super(); - this.sqlite = new SQLiteConnection(CapacitorSQLite); + // Use shared SQLITE instance - do not create new SQLiteConnection } // ============================================================================ @@ -71,6 +77,26 @@ export class CapacitorPlatformService } } + /** + * Check if database connection is open + */ + private async isDbOpen(db?: SQLiteDBConnection | null): Promise { + try { + return !!db && (await db.isDBOpen()).result === true; + } catch { + return false; + } + } + + /** + * Connection lifecycle logging (only when VITE_LOG_DB_CONN=true) + */ + private connLog(msg: string, extra?: unknown): void { + if (CapacitorPlatformService.LOG_CONN) { + logger.info(`[CPS][conn] ${msg}`, extra ?? ""); + } + } + /** * Apply SQLite PRAGMAs - single vetted sequence */ @@ -158,7 +184,10 @@ export class CapacitorPlatformService } } try { - await this.sqlite.closeConnection(this.dbName, false); + await SQLITE.closeConnection( + CapacitorPlatformService.DB_NAME, + CapacitorPlatformService.READ_ONLY, + ); } catch (closeConnError) { logger.debug( "[CapacitorPlatformService] Error closing connection during cleanup:", @@ -189,11 +218,7 @@ export class CapacitorPlatformService this.initializationPromise = (async () => { const conn = await this.runPhase("connect", async () => - this._getOrCreateConnection(this.dbName, { - version: 1, - encrypted: false, - mode: "no-encryption", - }), + this.getOrCreateConnection(), ); await this.runPhase("pragmas", async () => this.applyPragmas(conn)); @@ -213,60 +238,227 @@ export class CapacitorPlatformService } /** - * Get or create connection with desync repair (v3.5 - Minimal) + * Get or create connection with deterministic reuse (v3.6) + * + * Order: reuse in-memory → check manager → retrieve → create + * Never calls createConnection when connection already exists in native plugin */ - private async _getOrCreateConnection( - name: string, - opts: { - version?: number; - encrypted?: boolean; - mode?: string; - } = {}, - ): Promise { - // Check if we already have an open connection - if (this.db) { - try { - const isOpen = await this.db.isDBOpen(); - if (isOpen.result) { - return this.db; - } - } catch { - // Continue to retrieve/create - } + private async getOrCreateConnection(): Promise { + // 1) Reuse existing open connection + if (await this.isDbOpen(this.db)) { + this.connLog("reusing existing open connection"); + return this.db!; } - try { - const has = await this.sqlite.isConnection(name, false); - if (has.result) { - const conn = await this.sqlite.retrieveConnection(name, false); - this.db = conn; - const isOpen = await conn.isDBOpen(); - if (!isOpen.result) { - await conn.open(); - } - return conn; - } - } catch { - // Will attempt repair below - } - - // Repair JS/native desync - try { - await this.sqlite.closeConnection(name, false); - } catch { - // Ignore - } - - const conn = await this.sqlite.createConnection( - name, - false, - opts.mode ?? "no-encryption", - opts.version ?? 1, - opts.encrypted ?? false, + // 2) Check manager state FIRST (before attempting create) + const exists = await SQLITE.isConnection( + CapacitorPlatformService.DB_NAME, + CapacitorPlatformService.READ_ONLY, ); - this.db = conn; - await conn.open(); - return conn; + this.connLog(`isConnection? ${exists.result ? "yes" : "no"}`); + + if (exists.result) { + // 3) Retrieve and ensure open (before assigning to this.db) + try { + const db = await SQLITE.retrieveConnection( + CapacitorPlatformService.DB_NAME, + CapacitorPlatformService.READ_ONLY, + ); + if (db) { + try { + // Always open retrieved handles before assigning + if (!(await this.isDbOpen(db))) { + await db.open(); + } + this.db = db; + this.connLog("retrieved+open"); + return db; + } catch (openErr: unknown) { + const openMsg = + openErr instanceof Error ? openErr.message : String(openErr); + this.connLog( + `retrieved connection but open() failed: ${openMsg} → close stale`, + ); + // Fall through to close + create + } + } + // Stale registry entry; clean and fall through to create + this.connLog("retrieve returned null → close stale"); + } catch (retrieveErr: unknown) { + const retrieveMsg = + retrieveErr instanceof Error + ? retrieveErr.message + : String(retrieveErr); + // Retrieve threw error - treat as stale registry entry + this.connLog( + `retrieveConnection threw error: ${retrieveMsg} → close stale`, + ); + } + try { + await SQLITE.closeConnection( + CapacitorPlatformService.DB_NAME, + CapacitorPlatformService.READ_ONLY, + ); + } catch { + // Ignore close errors + } + } + + // 4) Create NEW (only if not found in manager) + // Self-heal: if create throws "already exists", try retrieve instead + try { + const db = await SQLITE.createConnection( + CapacitorPlatformService.DB_NAME, + CapacitorPlatformService.ENCRYPTED, + CapacitorPlatformService.MODE, + CapacitorPlatformService.VERSION, + CapacitorPlatformService.READ_ONLY, + ); + try { + await db.open(); + this.db = db; + this.connLog("created+open"); + return db; + } catch (openErr: unknown) { + const openMsg = + openErr instanceof Error ? openErr.message : String(openErr); + // If open fails on a newly created connection, treat as "already exists" case + if (openMsg.includes("does not exist") || openMsg.includes("already exists")) { + this.connLog( + `create succeeded but open() failed: ${openMsg} → treating as 'already exists'`, + ); + // Fall through to "already exists" recovery path + throw new Error("already exists"); + } + // Re-throw other open errors + throw openErr; + } + } catch (e: unknown) { + const errorMessage = e instanceof Error ? e.message : String(e); + if (errorMessage.includes("already exists")) { + // Self-heal: connection exists but wasn't found by isConnection + this.connLog("create threw 'already exists' → trying retrieve"); + try { + const db = await SQLITE.retrieveConnection( + CapacitorPlatformService.DB_NAME, + CapacitorPlatformService.READ_ONLY, + ); + if (db) { + try { + if (!(await this.isDbOpen(db))) { + await db.open(); + } + this.db = db; + this.connLog("retrieved+open (after create-throw)"); + return db; + } catch (openErr: unknown) { + const openMsg = + openErr instanceof Error ? openErr.message : String(openErr); + this.connLog( + `retrieved connection but open() failed: ${openMsg} → closing stale + recreate`, + ); + // Fall through to close + recreate + } + } + } catch (retrieveErr: unknown) { + const retrieveMsg = + retrieveErr instanceof Error + ? retrieveErr.message + : String(retrieveErr); + this.connLog( + `retrieveConnection threw error: ${retrieveMsg} → closing stale + recreate`, + ); + } + // Retrieve failed after "already exists" - close stale and recreate + this.connLog( + "retrieve after create-throw failed → closing stale + recreate", + ); + try { + await SQLITE.closeConnection( + CapacitorPlatformService.DB_NAME, + CapacitorPlatformService.READ_ONLY, + ); + // Small delay to allow native cleanup + await new Promise((resolve) => setTimeout(resolve, 50)); + } catch (closeErr) { + // Log but continue - connection might already be closed + this.connLog("closeConnection error (may be expected)", closeErr); + } + try { + const fresh = await SQLITE.createConnection( + CapacitorPlatformService.DB_NAME, + CapacitorPlatformService.ENCRYPTED, + CapacitorPlatformService.MODE, + CapacitorPlatformService.VERSION, + CapacitorPlatformService.READ_ONLY, + ); + try { + await fresh.open(); + this.db = fresh; + this.connLog("created+open (after stale close)"); + return fresh; + } catch (freshOpenErr: unknown) { + const freshOpenMsg = + freshOpenErr instanceof Error + ? freshOpenErr.message + : String(freshOpenErr); + this.connLog( + `recreate succeeded but open() failed: ${freshOpenMsg} → final retrieve attempt`, + ); + // Fall through to final retrieve attempt + throw freshOpenErr; + } + } catch (recreateErr: unknown) { + const recreateMsg = + recreateErr instanceof Error + ? recreateErr.message + : String(recreateErr); + // If recreate fails, try one more retrieve attempt + this.connLog( + `recreate failed: ${recreateMsg} → final retrieve attempt`, + ); + try { + const finalDb = await SQLITE.retrieveConnection( + CapacitorPlatformService.DB_NAME, + CapacitorPlatformService.READ_ONLY, + ); + if (finalDb) { + try { + if (!(await this.isDbOpen(finalDb))) { + await finalDb.open(); + } + this.db = finalDb; + this.connLog("retrieved+open (final attempt)"); + return finalDb; + } catch (finalOpenErr: unknown) { + const finalOpenMsg = + finalOpenErr instanceof Error + ? finalOpenErr.message + : String(finalOpenErr); + this.connLog( + `final retrieve succeeded but open() failed: ${finalOpenMsg}`, + ); + // Fall through to throw error + } + } + } catch (finalRetrieveErr: unknown) { + const finalRetrieveMsg = + finalRetrieveErr instanceof Error + ? finalRetrieveErr.message + : String(finalRetrieveErr); + this.connLog( + `final retrieve attempt also failed: ${finalRetrieveMsg}`, + ); + } + // All recovery attempts failed + throw new Error( + `Failed to establish database connection after recovery attempts: ${recreateMsg}`, + ); + } + } + // Re-throw if it's not an "already exists" error + throw e; + } } private async processQueue(): Promise { @@ -443,6 +635,40 @@ export class CapacitorPlatformService await this.initializeDatabase(); } + /** + * Explicit teardown for database connection (v3.6) + * + * Use this ONLY on explicit teardown scenarios: + * - Sign-out + * - App shutdown + * - Corruption recovery + * + * DO NOT call on route changes or component unmounts. + * + * @public + */ + public async teardown(): Promise { + if (await this.isDbOpen(this.db)) { + try { + await this.db?.close(); + } catch { + // Ignore close errors + } + } + try { + await SQLITE.closeConnection( + CapacitorPlatformService.DB_NAME, + CapacitorPlatformService.READ_ONLY, + ); + } catch { + // Ignore close errors + } + this.db = null; + this.initialized = false; + this.initializationPromise = null; + this.connLog("teardown complete"); + } + private async waitForInitialization(): Promise { // If we have an initialization promise, wait for it if (this.initializationPromise) { diff --git a/src/services/platforms/sqlite.ts b/src/services/platforms/sqlite.ts new file mode 100644 index 00000000..d353ebed --- /dev/null +++ b/src/services/platforms/sqlite.ts @@ -0,0 +1,20 @@ +/** + * Shared SQLite connection manager for Capacitor platform + * + * Ensures only one SQLiteConnection instance exists across the application, + * preventing connection desync issues and unnecessary connection recreation. + */ + +import { CapacitorSQLite, SQLiteConnection } from "@capacitor-community/sqlite"; + +/** + * Native Capacitor SQLite plugin instance + * This is the bridge to the native SQLite implementation + */ +export const CAP_SQLITE = CapacitorSQLite; + +/** + * Shared SQLite connection manager + * Use this instance throughout the application - do not create new SQLiteConnection instances + */ +export const SQLITE = new SQLiteConnection(CAP_SQLITE);