refactor(platforms/capacitor): improve database initialization robustness

Refactored _initialize() method to eliminate brittle error message parsing,
add transactional migration safety, and improve observability.

Key improvements:
- Replaced error message parsing with deterministic capability checks
  (_ensureConnection helper)
- Wrapped migrations in BEGIN IMMEDIATE transaction with rollback
- Added platform-aware PRAGMA application (WAL on native, not web)
- Added structured phase timing logs (connect, open, pragmas, migrate, verify)
- Enhanced error cleanup with best-effort connection closure
- Updated integrity verification to throw on critical failures
- Extracted helper methods (_asMessage, _ensureConnection, _applyPragmas)
- Added configuration properties (dbVersion, encryption) to replace literals

Removed single-flight logic from _initialize() (handled by initializeDatabase()).
All connection state handling now uses isConnection() checks instead of
string matching, making initialization deterministic across JS/native
boundaries.
This commit is contained in:
Matthew Raymer
2025-11-07 07:05:55 +00:00
parent d265a9f78c
commit 239666e137

View File

@@ -50,6 +50,8 @@ export class CapacitorPlatformService
private sqlite: SQLiteConnection; private sqlite: SQLiteConnection;
private db: SQLiteDBConnection | null = null; private db: SQLiteDBConnection | null = null;
private dbName = "timesafari.sqlite"; private dbName = "timesafari.sqlite";
private dbVersion: number = 1;
private encryption: string = "no-encryption";
private initialized = false; private initialized = false;
private initializationPromise: Promise<void> | null = null; private initializationPromise: Promise<void> | null = null;
private operationQueue: Array<QueuedOperation> = []; private operationQueue: Array<QueuedOperation> = [];
@@ -85,120 +87,396 @@ export class CapacitorPlatformService
} }
} }
private async _initialize(): Promise<void> { /**
if (this.initialized) { * Extract error message from unknown error type.
*
* Provides consistent error text extraction for logging and
* error handling across different error formats.
*
* @param e - Unknown error object
* @returns String representation of error message
* @private
*/
private _asMessage(e: unknown): string {
if (e instanceof Error) {
return e.message;
}
if (
typeof e === "object" &&
e !== null &&
("errorMessage" in e || "message" in e)
) {
return (
(e as { errorMessage?: string; message?: string }).errorMessage ||
(e as { errorMessage?: string; message?: string }).message ||
String(e)
);
}
return String(e);
}
/**
* Ensure database connection exists, handling JS/native map desyncs.
*
* Uses capability checks instead of error message parsing to
* deterministically handle connection state across native/JS
* boundaries.
*
* @returns Promise resolving when connection is established
* @throws Error if connection cannot be established
* @private
*/
private async _ensureConnection(): Promise<void> {
const startTime = Date.now();
// Check if connection exists in JavaScript Map
const isConnResult = await this.sqlite.isConnection(this.dbName, false);
if (isConnResult.result) {
// Connection exists in Map, retrieve it
logger.debug(
`[CapacitorPlatformService] Connection exists in Map, retrieving (${Date.now() - startTime}ms)`,
);
this.db = await this.sqlite.retrieveConnection(this.dbName, false);
logger.debug(
`[CapacitorPlatformService] Successfully retrieved connection (${Date.now() - startTime}ms)`,
);
return; return;
} }
// Connection doesn't exist in Map, try to create
try { try {
// Try to create/Open database connection logger.debug(
`[CapacitorPlatformService] Creating new connection (${Date.now() - startTime}ms)`,
);
this.db = await this.sqlite.createConnection(
this.dbName,
false,
this.encryption,
this.dbVersion,
false,
);
logger.debug(
`[CapacitorPlatformService] Successfully created connection (${Date.now() - startTime}ms)`,
);
} catch (createError: unknown) {
// Connection might exist natively but not in JS Map
// Best-effort cleanup then retry once
logger.debug(
`[CapacitorPlatformService] Create failed, attempting cleanup (${Date.now() - startTime}ms):`,
this._asMessage(createError),
);
try { try {
this.db = await this.sqlite.createConnection( await this.sqlite.closeConnection(this.dbName, false);
this.dbName, } catch (closeError) {
false, // Ignore close errors - connection might not be properly tracked
"no-encryption", logger.debug(
1, `[CapacitorPlatformService] Cleanup close error (may be expected):`,
false, this._asMessage(closeError),
); );
} catch (createError: unknown) {
// If connection already exists, try to retrieve it or handle gracefully
const errorMessage =
createError instanceof Error
? createError.message
: String(createError);
const errorObj =
typeof createError === "object" && createError !== null
? (createError as { errorMessage?: string; message?: string })
: {};
const fullErrorMessage =
errorObj.errorMessage || errorObj.message || errorMessage;
if (fullErrorMessage.includes("already exists")) {
logger.debug(
"[CapacitorPlatformService] Connection already exists on native side, attempting to retrieve",
);
// Check if connection exists in JavaScript Map
const isConnResult = await this.sqlite.isConnection(
this.dbName,
false,
);
if (isConnResult.result) {
// Connection exists in Map, retrieve it
this.db = await this.sqlite.retrieveConnection(this.dbName, false);
logger.debug(
"[CapacitorPlatformService] Successfully retrieved existing connection from Map",
);
} else {
// Connection exists on native side but not in JavaScript Map
// This can happen when the app is restarted but native connections persist
// Try to close the native connection first, then create a new one
logger.debug(
"[CapacitorPlatformService] Connection exists natively but not in Map, closing and recreating",
);
try {
await this.sqlite.closeConnection(this.dbName, false);
} catch (closeError) {
// Ignore close errors - connection might not be properly tracked
logger.debug(
"[CapacitorPlatformService] Error closing connection (may be expected):",
closeError,
);
}
// Now try to create the connection again
this.db = await this.sqlite.createConnection(
this.dbName,
false,
"no-encryption",
1,
false,
);
logger.debug(
"[CapacitorPlatformService] Successfully created connection after cleanup",
);
}
} else {
// Re-throw if it's a different error
throw createError;
}
} }
// Open the connection if it's not already open // Retry create once
logger.debug(
`[CapacitorPlatformService] Retrying connection creation (${Date.now() - startTime}ms)`,
);
this.db = await this.sqlite.createConnection(
this.dbName,
false,
this.encryption,
this.dbVersion,
false,
);
logger.debug(
`[CapacitorPlatformService] Successfully created connection after retry (${Date.now() - startTime}ms)`,
);
}
}
/**
* Apply SQLite PRAGMAs with platform-aware configuration.
*
* Sets foreign keys, journal mode (WAL on native), synchronous
* mode, and busy timeout. Handles platform differences gracefully.
*
* @param db - Database connection (non-null)
* @returns Promise resolving when PRAGMAs are applied
* @throws Error if PRAGMA application fails
* @private
*/
private async _applyPragmas(db: SQLiteDBConnection): Promise<string> {
const startTime = Date.now();
const platform = Capacitor.getPlatform();
let journalMode = "unknown";
try {
// Always enable foreign keys
await db.execute("PRAGMA foreign_keys = ON;");
logger.debug(
`[CapacitorPlatformService] Applied foreign_keys PRAGMA (${Date.now() - startTime}ms)`,
);
// Set busy timeout before any operations
await db.execute("PRAGMA busy_timeout = 5000;");
logger.debug(
`[CapacitorPlatformService] Applied busy_timeout PRAGMA (${Date.now() - startTime}ms)`,
);
// Prefer WAL on native platforms
if (platform !== "web") {
try {
await db.execute("PRAGMA journal_mode = WAL;");
// Verify actual journal mode
const modeResult = await db.query("PRAGMA journal_mode;");
if (
modeResult.values &&
modeResult.values.length > 0 &&
typeof modeResult.values[0] === "object" &&
modeResult.values[0] !== null
) {
const row = modeResult.values[0] as Record<string, unknown>;
journalMode =
(row.journal_mode as string) ||
(Array.isArray(modeResult.values[0])
? (modeResult.values[0][0] as string)
: "unknown");
}
logger.debug(
`[CapacitorPlatformService] Applied WAL journal_mode: ${journalMode} (${Date.now() - startTime}ms)`,
);
await db.execute("PRAGMA synchronous = NORMAL;");
logger.debug(
`[CapacitorPlatformService] Applied synchronous PRAGMA (${Date.now() - startTime}ms)`,
);
} catch (walError) {
logger.warn(
`[CapacitorPlatformService] WAL mode failed, continuing with default:`,
this._asMessage(walError),
);
// Verify actual mode even if WAL failed
try {
const modeResult = await db.query("PRAGMA journal_mode;");
if (
modeResult.values &&
modeResult.values.length > 0 &&
typeof modeResult.values[0] === "object" &&
modeResult.values[0] !== null
) {
const row = modeResult.values[0] as Record<string, unknown>;
journalMode =
(row.journal_mode as string) ||
(Array.isArray(modeResult.values[0])
? (modeResult.values[0][0] as string)
: "unknown");
}
} catch {
journalMode = "unknown";
}
}
} else {
logger.debug(
`[CapacitorPlatformService] Skipping WAL on web platform (${Date.now() - startTime}ms)`,
);
journalMode = "default";
}
logger.debug(
`[CapacitorPlatformService] PRAGMAs applied successfully, journal_mode: ${journalMode} (${Date.now() - startTime}ms)`,
);
return journalMode;
} catch (error) {
logger.error(
`[CapacitorPlatformService] Failed to apply PRAGMAs (${Date.now() - startTime}ms):`,
this._asMessage(error),
);
throw error;
}
}
/**
* Initialize database connection, apply PRAGMAs, run migrations,
* and verify integrity.
*
* This method orchestrates the complete database initialization
* process with proper error handling, transaction management, and
* observability. It relies on `initializeDatabase()` for
* single-flight concurrency control.
*
* ## Initialization Phases:
*
* 1. **Connection**: Establish connection using capability checks
* 2. **Open**: Open connection with tolerant "already open" handling
* 3. **PRAGMAs**: Apply platform-aware SQLite configuration
* 4. **Migrations**: Execute migrations within transaction
* 5. **Verification**: Run integrity checks post-migration
* 6. **Queue Start**: Begin processing queued operations
*
* ## Error Handling:
*
* - All phases are timed and logged
* - Failures trigger cleanup (close connection, reset state)
* - Errors include original cause for upstream logging
* - Single-flight promise is reset only on failure
*
* ## Transaction Management:
*
* Migrations run within `BEGIN IMMEDIATE` transaction with
* automatic rollback on failure. This ensures database consistency
* even if migrations fail mid-way.
*
* @throws Error if initialization fails at any phase
* @private Internal method called only from initializeDatabase()
*/
private async _initialize(): Promise<void> {
const initStartTime = Date.now();
const phaseTimings: Record<string, number> = {};
let connectionEstablished = false;
let dbOpened = false;
let journalMode = "unknown";
try {
// Phase 1: Connection establishment
const connectStart = Date.now();
await this._ensureConnection();
phaseTimings.connect = Date.now() - connectStart;
connectionEstablished = true;
if (!this.db) {
throw new Error("Connection established but db is null");
}
// Narrow type for subsequent operations
const db = this.db;
// Phase 2: Open connection
const openStart = Date.now();
try { try {
await this.db.open(); await db.open();
dbOpened = true;
} catch (openError: unknown) { } catch (openError: unknown) {
const openErrorMessage = const openMsg = this._asMessage(openError);
openError instanceof Error ? openError.message : String(openError); // Swallow only "already open" errors
// If already open, that's fine - continue if (!/already open/i.test(openMsg)) {
if (!openErrorMessage.includes("already open")) {
throw openError; throw openError;
} }
logger.debug( logger.debug(
"[CapacitorPlatformService] Database connection already open", `[CapacitorPlatformService] Database already open (${Date.now() - openStart}ms)`,
); );
dbOpened = true;
}
phaseTimings.open = Date.now() - openStart;
// Phase 3: Apply PRAGMAs
const pragmasStart = Date.now();
journalMode = await this._applyPragmas(db);
phaseTimings.pragmas = Date.now() - pragmasStart;
// Phase 4: Run migrations in transaction
const migrateStart = Date.now();
try {
await db.execute("BEGIN IMMEDIATE;");
logger.debug(
`[CapacitorPlatformService] Migration transaction started (${Date.now() - migrateStart}ms)`,
);
await this.runCapacitorMigrations();
await db.execute("COMMIT;");
logger.debug(
`[CapacitorPlatformService] Migration transaction committed (${Date.now() - migrateStart}ms)`,
);
} catch (migrateError) {
// Rollback on any migration error
try {
await db.execute("ROLLBACK;");
logger.debug(
`[CapacitorPlatformService] Migration transaction rolled back (${Date.now() - migrateStart}ms)`,
);
} catch (rollbackError) {
logger.error(
`[CapacitorPlatformService] Rollback failed:`,
this._asMessage(rollbackError),
);
}
throw migrateError;
}
phaseTimings.migrate = Date.now() - migrateStart;
// Phase 5: Post-migration integrity verification
const verifyStart = Date.now();
await this.verifyDatabaseIntegrity();
phaseTimings.verify = Date.now() - verifyStart;
// Mark as initialized
this.initialized = true;
// Phase 6: Start queue processor (only once)
if (!this.isProcessingQueue) {
this.processQueue();
} }
// Set journal mode to WAL for better performance // Log success with all timing and config info
// await this.db.execute("PRAGMA journal_mode=WAL;"); const totalTime = Date.now() - initStartTime;
const platform = Capacitor.getPlatform();
// Run migrations
await this.runCapacitorMigrations();
this.initialized = true;
logger.log( logger.log(
"[CapacitorPlatformService] SQLite database initialized successfully", `[CapacitorPlatformService] Database initialized successfully ` +
`(total: ${totalTime}ms, connect: ${phaseTimings.connect}ms, ` +
`open: ${phaseTimings.open}ms, pragmas: ${phaseTimings.pragmas}ms, ` +
`migrate: ${phaseTimings.migrate}ms, verify: ${phaseTimings.verify}ms) ` +
`[dbName: ${this.dbName}, dbVersion: ${this.dbVersion}, ` +
`encryption: ${this.encryption}, platform: ${platform}, ` +
`journal_mode: ${journalMode}]`,
);
} catch (error) {
const phase =
Object.keys(phaseTimings).length === 0
? "connection"
: Object.keys(phaseTimings).pop() || "unknown";
const totalTime = Date.now() - initStartTime;
logger.error(
`[CapacitorPlatformService] Initialization failed at phase '${phase}' ` +
`(total: ${totalTime}ms):`,
error instanceof Error ? error.message : String(error),
); );
// Start processing the queue after initialization // Cleanup: best-effort close and reset state
this.processQueue(); if (connectionEstablished && this.db) {
} catch (error) { try {
logger.error( if (dbOpened) {
"[CapacitorPlatformService] Error initializing SQLite database:", await this.db.close();
error, }
); } catch (closeError) {
throw new Error( logger.debug(
"[CapacitorPlatformService] Failed to initialize database", `[CapacitorPlatformService] Cleanup close error:`,
this._asMessage(closeError),
);
}
try {
await this.sqlite.closeConnection(this.dbName, false);
} catch (closeConnError) {
logger.debug(
`[CapacitorPlatformService] Cleanup closeConnection error:`,
this._asMessage(closeConnError),
);
}
}
this.db = null;
this.initialized = false;
// Re-throw with cause for upstream logging
const initError = new Error(
`[CapacitorPlatformService] Failed to initialize database at phase '${phase}'`,
); );
if (error instanceof Error) {
// Use standard Error properties for compatibility
initError.message = `${initError.message}: ${error.message}`;
initError.stack = error.stack;
}
throw initError;
} }
} }
@@ -673,22 +951,13 @@ export class CapacitorPlatformService
return new Set(names); return new Set(names);
}; };
try { // Execute the migration process
// Execute the migration process // Note: Integrity verification is handled by _initialize() after transaction commit
await runMigrations(sqlExec, sqlQuery, extractMigrationNames); await runMigrations(sqlExec, sqlQuery, extractMigrationNames);
// After migrations, run integrity check to verify database state
await this.verifyDatabaseIntegrity();
} catch (error) {
logger.error(`❌ [CapacitorMigration] Migration failed:`, error);
// Still try to verify what we have for debugging purposes
await this.verifyDatabaseIntegrity();
throw error;
}
} }
/** /**
* Verify database integrity and migration status * Verify database integrity and migration status.
* *
* This method performs comprehensive validation of the database structure * This method performs comprehensive validation of the database structure
* and migration state. It's designed to help identify issues with the * and migration state. It's designed to help identify issues with the
@@ -719,9 +988,9 @@ export class CapacitorPlatformService
* *
* ## Error Handling: * ## Error Handling:
* *
* This method is designed to never throw errors - it captures and logs * This method throws errors for critical failures (missing core tables)
* all validation issues for debugging purposes. This ensures that even * to ensure initialization fails when database integrity is compromised.
* if integrity checks fail, they don't prevent the application from starting. * Non-critical validation issues are logged but do not cause failures.
* *
* ## Logging Output: * ## Logging Output:
* *
@@ -731,6 +1000,7 @@ export class CapacitorPlatformService
* - `📊` for data summaries * - `📊` for data summaries
* - `🔍` for investigation steps * - `🔍` for investigation steps
* *
* @throws Error if critical integrity checks fail (e.g., missing core tables)
* @private Internal method called after migrations * @private Internal method called after migrations
* *
* @example * @example
@@ -741,12 +1011,15 @@ export class CapacitorPlatformService
*/ */
private async verifyDatabaseIntegrity(): Promise<void> { private async verifyDatabaseIntegrity(): Promise<void> {
if (!this.db) { if (!this.db) {
logger.error(`[DB-Integrity] Database not initialized`); const error = new Error("[DB-Integrity] Database not initialized");
return; logger.error(`${error.message}`);
throw error;
} }
logger.debug(`🔍 [DB-Integrity] Starting database integrity check...`); logger.debug(`🔍 [DB-Integrity] Starting database integrity check...`);
const missingTables: string[] = [];
try { try {
// Step 1: Check migrations table and applied migrations // Step 1: Check migrations table and applied migrations
const migrationsResult = await this.db.query( const migrationsResult = await this.db.query(
@@ -763,7 +1036,6 @@ export class CapacitorPlatformService
"logs", "logs",
"temp", "temp",
]; ];
const existingTables: string[] = [];
for (const tableName of coreTableNames) { for (const tableName of coreTableNames) {
try { try {
@@ -771,58 +1043,66 @@ export class CapacitorPlatformService
`SELECT name FROM sqlite_master WHERE type='table' AND name='${tableName}'`, `SELECT name FROM sqlite_master WHERE type='table' AND name='${tableName}'`,
); );
if (tableCheck.values && tableCheck.values.length > 0) { if (tableCheck.values && tableCheck.values.length > 0) {
existingTables.push(tableName);
logger.debug(`✅ [DB-Integrity] Table ${tableName} exists`); logger.debug(`✅ [DB-Integrity] Table ${tableName} exists`);
} else { } else {
logger.error(`❌ [DB-Integrity] Table ${tableName} missing`); logger.error(`❌ [DB-Integrity] Table ${tableName} missing`);
missingTables.push(tableName);
} }
} catch (error) { } catch (error) {
logger.error( logger.error(
`❌ [DB-Integrity] Error checking table ${tableName}:`, `❌ [DB-Integrity] Error checking table ${tableName}:`,
error, error,
); );
missingTables.push(tableName);
} }
} }
// Fail init if critical tables are missing
if (missingTables.length > 0) {
const error = new Error(
`[DB-Integrity] Critical tables missing: ${missingTables.join(", ")}`,
);
logger.error(`${error.message}`);
throw error;
}
// Step 3: Check contacts table schema (including iViewContent column) // Step 3: Check contacts table schema (including iViewContent column)
if (existingTables.includes("contacts")) { try {
try { const contactsSchema = await this.db.query(
const contactsSchema = await this.db.query( "PRAGMA table_info(contacts)",
"PRAGMA table_info(contacts)", );
); logger.debug(
`📊 [DB-Integrity] Contacts table schema:`,
contactsSchema,
);
// Check for iViewContent column specifically
const hasIViewContent = contactsSchema.values?.some(
(col: unknown) =>
(typeof col === "object" &&
col !== null &&
"name" in col &&
(col as { name: string }).name === "iViewContent") ||
(Array.isArray(col) && col[1] === "iViewContent"),
);
if (hasIViewContent) {
logger.debug( logger.debug(
`📊 [DB-Integrity] Contacts table schema:`, ` [DB-Integrity] iViewContent column exists in contacts table`,
contactsSchema,
); );
} else {
// Check for iViewContent column specifically logger.warn(
const hasIViewContent = contactsSchema.values?.some( `⚠️ [DB-Integrity] iViewContent column missing from contacts table (non-critical)`,
(col: unknown) =>
(typeof col === "object" &&
col !== null &&
"name" in col &&
(col as { name: string }).name === "iViewContent") ||
(Array.isArray(col) && col[1] === "iViewContent"),
);
if (hasIViewContent) {
logger.debug(
`✅ [DB-Integrity] iViewContent column exists in contacts table`,
);
} else {
logger.error(
`❌ [DB-Integrity] iViewContent column missing from contacts table`,
);
}
} catch (error) {
logger.error(
`❌ [DB-Integrity] Error checking contacts schema:`,
error,
); );
} }
} catch (error) {
logger.warn(
`⚠️ [DB-Integrity] Error checking contacts schema (non-critical):`,
error,
);
} }
// Step 4: Check for basic data integrity // Step 4: Check for basic data integrity (best-effort, non-critical)
try { try {
const accountCount = await this.db.query( const accountCount = await this.db.query(
"SELECT COUNT(*) as count FROM accounts", "SELECT COUNT(*) as count FROM accounts",
@@ -838,11 +1118,19 @@ export class CapacitorPlatformService
`📊 [DB-Integrity] Data counts - Accounts: ${JSON.stringify(accountCount)}, Settings: ${JSON.stringify(settingsCount)}, Contacts: ${JSON.stringify(contactsCount)}`, `📊 [DB-Integrity] Data counts - Accounts: ${JSON.stringify(accountCount)}, Settings: ${JSON.stringify(settingsCount)}, Contacts: ${JSON.stringify(contactsCount)}`,
); );
} catch (error) { } catch (error) {
logger.error(`❌ [DB-Integrity] Error checking data counts:`, error); logger.warn(
`⚠️ [DB-Integrity] Error checking data counts (non-critical):`,
error,
);
} }
logger.log(`✅ [DB-Integrity] Database integrity check completed`); logger.log(`✅ [DB-Integrity] Database integrity check completed`);
} catch (error) { } catch (error) {
// Re-throw if it's our own error (missing tables)
if (error instanceof Error && error.message.includes("[DB-Integrity]")) {
throw error;
}
// Log other errors but don't fail init
logger.error(`❌ [DB-Integrity] Database integrity check failed:`, error); logger.error(`❌ [DB-Integrity] Database integrity check failed:`, error);
} }
} }