diff --git a/src/services/platforms/CapacitorPlatformService.ts b/src/services/platforms/CapacitorPlatformService.ts index 4103e881..526b3421 100644 --- a/src/services/platforms/CapacitorPlatformService.ts +++ b/src/services/platforms/CapacitorPlatformService.ts @@ -146,10 +146,15 @@ export class CapacitorPlatformService } /** - * Internal initialization method (v3.3) + * Internal initialization method (v3.3.3) * * Order: PRAGMAs (temp connection, no transactions) → connect → open → migrations (single txn) → verify * + * ORDERING GUARD (v3.3.3+): + * - PRAGMAs MUST run before any execute/executeSet operations + * - This ensures database-level settings are applied before migrations + * - If ordering changes, add unit test that fails on execute before PRAGMAs + * * @private Internal method - use initializeDatabase() instead */ private async _initialize(): Promise { @@ -163,6 +168,7 @@ export class CapacitorPlatformService try { // Phase 1: Apply PRAGMAs on temporary connection (MUST be before main connection) // This ensures PRAGMAs run outside any transaction context + // ORDERING GUARD: No execute/executeSet operations should occur before this phase logger.debug( "[CapacitorPlatformService] Starting PRAGMA phase (before main connection)", ); @@ -281,12 +287,17 @@ export class CapacitorPlatformService * * Implements retrieve-first, create-second, close+create-last pattern. * Short-circuits if fallback PRAGMA method already set this.db. + * + * ORDERING GUARD (v3.3.3+): + * - This method MUST be called AFTER _applyPragmasOnTempConnection() + * - PRAGMAs must run before any execute/executeSet operations + * - If this.db is already set by fallback PRAGMA path, return immediately */ private async _ensureConnection(): Promise { - // 1) If fallback already set a live handle, use it + // 1) Short-circuit: If fallback PRAGMA method already set this.db, use it (invariant #3) if (this.db) { logger.log( - "[CapacitorPlatformService] [DB] ensure: already have connection", + "[CapacitorPlatformService] [DB] ensure: already have connection (from PRAGMA fallback)", ); return; } @@ -469,7 +480,7 @@ export class CapacitorPlatformService // Foreign keys (safe via run) await pragmaConn.run("PRAGMA foreign_keys = ON;", []); - // journal_mode via query (returns a row) + // journal_mode via query (returns a row) - invariant #2 let journalMode = "unknown"; if (platform !== "web") { try { @@ -477,16 +488,33 @@ export class CapacitorPlatformService const row = res.values?.[0] as Record | undefined; const key = row ? Object.keys(row)[0] : undefined; journalMode = (key ? (row![key] as string) : undefined) ?? "unknown"; - } catch { - // leave as unknown + + // iOS compatibility: WAL may be refused on read-only bundles + // Log but don't throw - device may not support WAL + if (journalMode !== "wal") { + logger.debug( + `[CapacitorPlatformService] WAL mode not effective (got: ${journalMode}), continuing with default`, + ); + } + } catch (walError) { + // Non-fatal: WAL failure is acceptable (device may not support it) + logger.debug( + "[CapacitorPlatformService] WAL mode failed, continuing with default:", + this._errMsg(walError), + ); + journalMode = "default"; } + // Only set synchronous when WAL is effective (invariant #4) if (journalMode === "wal") { - // Only set NORMAL when WAL took effect try { await pragmaConn.run("PRAGMA synchronous = NORMAL;", []); - } catch { - // Non-fatal if synchronous fails + } catch (syncError) { + // Non-fatal if synchronous fails (may be in transaction) + logger.debug( + "[CapacitorPlatformService] synchronous=NORMAL failed (non-critical):", + this._errMsg(syncError), + ); } } } else { @@ -549,6 +577,22 @@ export class CapacitorPlatformService * * v3.3.3: Fixed busy_timeout PRAGMA to use query() instead of run() to avoid * "error code 100: another row available" on some Capacitor SQLite builds. + * + * INVARIANTS (v3.3.3+ - must not regress): + * 1. foreign_keys: Always set via run() (no return value) + * 2. journal_mode: Always set via query() (returns mode as row) + * - On iOS/web: If WAL refused, log + continue (journalMode = "unknown") + * - Never throw on WAL failure (device may not support it) + * 3. busy_timeout: Always set via query() first, fallback to run() if needed + * - Swallow benign "another row available" errors + * 4. synchronous: SKIPPED in fallback path (often fails in transaction) + * - Only set in temp path when journal_mode === 'wal' + * 5. MUST set this.db before returning (even on error, if conn exists) + * + * iOS Compatibility: + * - WAL may be refused on read-only bundles or protected file modes + * - If journal_mode !== 'wal', synchronous is not set (as documented) + * - All PRAGMA failures are non-fatal (log + continue) */ private async _applyPragmasOnExistingConnection(): Promise<{ journalMode: string; @@ -628,20 +672,36 @@ export class CapacitorPlatformService // Ignore errors } - // journal_mode via query (safe even if txn-wrapped) + // journal_mode via query (safe even if txn-wrapped) - invariant #2 if (platform !== "web") { try { const res = await conn.query("PRAGMA journal_mode = WAL;"); const row = res.values?.[0] as Record | undefined; const key = row ? Object.keys(row)[0] : undefined; journalMode = (key ? (row![key] as string) : undefined) ?? "unknown"; - } catch { - // leave as unknown + + // iOS compatibility: WAL may be refused on read-only bundles + // Log but don't throw - device may not support WAL + if (journalMode !== "wal") { + logger.debug( + `[CapacitorPlatformService] [PRAGMA] fallback: WAL mode not effective (got: ${journalMode}), continuing`, + ); + } + } catch (walError) { + // Non-fatal: WAL failure is acceptable (device may not support it) + logger.debug( + "[CapacitorPlatformService] [PRAGMA] fallback: WAL mode failed, continuing:", + this._errMsg(walError), + ); + journalMode = "default"; } } else { journalMode = "default"; } + // NOTE: synchronous is SKIPPED in fallback path (invariant #4) + // It often fails in transaction context, and temp path handles it when WAL is effective + // busy_timeout: use query() and ignore the row to avoid "another row available" (v3.3.3) // v3.3.3: Fixed "error code 100: another row available" by using query() instead of run() // Some Capacitor SQLite builds return busy_timeout as a row, which causes run() to fail @@ -668,12 +728,12 @@ export class CapacitorPlatformService } } - // CRUCIAL: Set this.db before returning + // CRUCIAL: Set this.db before returning (invariant #5) this.db = conn; logger.log( - `[CapacitorPlatformService] [PRAGMA] fallback: PRAGMAs applied; this.db set (journal_mode=${journalMode})`, + `[CapacitorPlatformService] [PRAGMA] fallback: PRAGMAs applied; this.db set (journal_mode=${journalMode}, v3.3.3)`, ); - logger.log("[CapacitorPlatformService] PRAGMA path: fallback"); + logger.log("[CapacitorPlatformService] PRAGMA path: fallback (v3.3.3)"); return { journalMode, path: "fallback" as const }; } catch (e) {