refactor: implement HMR-safe singleton and improve database connection recovery

- Refactor PlatformServiceFactory to use globalThis-based singleton storage
  - Store singleton in globalThis/window/self for persistence across module reloads
  - Add comprehensive debug logging for singleton lifecycle tracking
  - Implement Proxy-based lazy initialization to avoid circular dependencies
  - Add HMR support to preserve singleton across hot module replacement

- Enhance CapacitorPlatformService database connection recovery
  - Improve getOrCreateConnection() with robust error handling
  - Add recovery path for 'already exists' desync scenarios
  - Wrap retrieveConnection and db.open() calls in try-catch blocks
  - Handle cases where retrieveConnection throws instead of returning null
  - Add 50ms delay after closeConnection to allow native cleanup

- Add shared SQLite connection manager (sqlite.ts)
  - Centralize SQLiteConnection instance creation
  - Prevent connection desync issues across the application

This addresses the issue where PlatformService singleton was being
recreated on navigation, causing repeated database initialization
attempts and connection desync errors.
This commit is contained in:
Matthew Raymer
2025-11-09 08:55:30 +00:00
parent 523f88fd0d
commit ceaf4ede11
3 changed files with 465 additions and 114 deletions

View File

@@ -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<string, unknown>)[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
});
}
/**
* Gets or creates the singleton instance of PlatformService.
* Creates the appropriate platform-specific implementation based on environment.
*
* @returns {PlatformService} The singleton instance of PlatformService
* Legacy factory class for backward compatibility
* @deprecated Use `PlatformSvc` directly instead
*/
export class PlatformServiceFactory {
/**
* 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,
};
}
}

View File

@@ -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<void> | null = null;
private operationQueue = new OperationQueue<SQLiteDBConnection>();
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<boolean> {
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<SQLiteDBConnection> {
// 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<SQLiteDBConnection> {
// 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<void> {
@@ -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<void> {
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<void> {
// If we have an initialization promise, wait for it
if (this.initializationPromise) {

View File

@@ -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);