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 db: SQLiteDBConnection | null = null;
private dbName = "timesafari.sqlite";
private dbVersion: number = 1;
private encryption: string = "no-encryption";
private initialized = false;
private initializationPromise: Promise<void> | null = null;
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;
}
// Connection doesn't exist in Map, try to create
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 {
this.db = await this.sqlite.createConnection(
this.dbName,
false,
"no-encryption",
1,
false,
await this.sqlite.closeConnection(this.dbName, false);
} catch (closeError) {
// Ignore close errors - connection might not be properly tracked
logger.debug(
`[CapacitorPlatformService] Cleanup close error (may be expected):`,
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 {
await this.db.open();
await db.open();
dbOpened = true;
} catch (openError: unknown) {
const openErrorMessage =
openError instanceof Error ? openError.message : String(openError);
// If already open, that's fine - continue
if (!openErrorMessage.includes("already open")) {
const openMsg = this._asMessage(openError);
// Swallow only "already open" errors
if (!/already open/i.test(openMsg)) {
throw openError;
}
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
// await this.db.execute("PRAGMA journal_mode=WAL;");
// Run migrations
await this.runCapacitorMigrations();
this.initialized = true;
// Log success with all timing and config info
const totalTime = Date.now() - initStartTime;
const platform = Capacitor.getPlatform();
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
this.processQueue();
} catch (error) {
logger.error(
"[CapacitorPlatformService] Error initializing SQLite database:",
error,
);
throw new Error(
"[CapacitorPlatformService] Failed to initialize database",
// Cleanup: best-effort close and reset state
if (connectionEstablished && this.db) {
try {
if (dbOpened) {
await this.db.close();
}
} catch (closeError) {
logger.debug(
`[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);
};
try {
// Execute the migration process
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;
}
// Execute the migration process
// Note: Integrity verification is handled by _initialize() after transaction commit
await runMigrations(sqlExec, sqlQuery, extractMigrationNames);
}
/**
* Verify database integrity and migration status
* Verify database integrity and migration status.
*
* This method performs comprehensive validation of the database structure
* and migration state. It's designed to help identify issues with the
@@ -719,9 +988,9 @@ export class CapacitorPlatformService
*
* ## Error Handling:
*
* This method is designed to never throw errors - it captures and logs
* all validation issues for debugging purposes. This ensures that even
* if integrity checks fail, they don't prevent the application from starting.
* This method throws errors for critical failures (missing core tables)
* to ensure initialization fails when database integrity is compromised.
* Non-critical validation issues are logged but do not cause failures.
*
* ## Logging Output:
*
@@ -731,6 +1000,7 @@ export class CapacitorPlatformService
* - `📊` for data summaries
* - `🔍` for investigation steps
*
* @throws Error if critical integrity checks fail (e.g., missing core tables)
* @private Internal method called after migrations
*
* @example
@@ -741,12 +1011,15 @@ export class CapacitorPlatformService
*/
private async verifyDatabaseIntegrity(): Promise<void> {
if (!this.db) {
logger.error(`[DB-Integrity] Database not initialized`);
return;
const error = new Error("[DB-Integrity] Database not initialized");
logger.error(`${error.message}`);
throw error;
}
logger.debug(`🔍 [DB-Integrity] Starting database integrity check...`);
const missingTables: string[] = [];
try {
// Step 1: Check migrations table and applied migrations
const migrationsResult = await this.db.query(
@@ -763,7 +1036,6 @@ export class CapacitorPlatformService
"logs",
"temp",
];
const existingTables: string[] = [];
for (const tableName of coreTableNames) {
try {
@@ -771,58 +1043,66 @@ export class CapacitorPlatformService
`SELECT name FROM sqlite_master WHERE type='table' AND name='${tableName}'`,
);
if (tableCheck.values && tableCheck.values.length > 0) {
existingTables.push(tableName);
logger.debug(`✅ [DB-Integrity] Table ${tableName} exists`);
} else {
logger.error(`❌ [DB-Integrity] Table ${tableName} missing`);
missingTables.push(tableName);
}
} catch (error) {
logger.error(
`❌ [DB-Integrity] Error checking table ${tableName}:`,
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)
if (existingTables.includes("contacts")) {
try {
const contactsSchema = await this.db.query(
"PRAGMA table_info(contacts)",
);
try {
const contactsSchema = await this.db.query(
"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(
`📊 [DB-Integrity] Contacts table schema:`,
contactsSchema,
` [DB-Integrity] iViewContent column exists in contacts table`,
);
// 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(
`✅ [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,
} else {
logger.warn(
`⚠️ [DB-Integrity] iViewContent column missing from contacts table (non-critical)`,
);
}
} 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 {
const accountCount = await this.db.query(
"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)}`,
);
} 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`);
} 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);
}
}