refactor-initialize #221

Open
anomalist wants to merge 13 commits from refactor-initialize into master
3 changed files with 465 additions and 114 deletions
Showing only changes of commit ceaf4ede11 - Show all commits

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
});
}
/**
* 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,
};
}
}

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