import type { SQLiteDatabase } from 'expo-sqlite'; import { migrateDatabase } from './migrations'; import { DATABASE_NAME } from './schema'; let database: SQLiteDatabase | null = null; let opening: Promise | null = null; async function loadSqlite(): Promise { return import('expo-sqlite'); } /** * expo-sqlite (Android, encore non corrigé en 57.x, cf. expo/expo#48999) peut * « empoisonner » une connexion : après un teardown de runtime (reload dev, * reconstitution d'Activity) de vieux handles natifs sont double-fermés et * chaque requête suivante rejette un NullPointerException nu. Un simple reopen * renvoie l'objet empoisonné en cache ; seul un reconnect (useNewConnection) * redonne une connexion vive. */ export function isBrokenConnectionError(error: unknown): boolean { if (!(error instanceof Error)) return false; return ( error.message.includes("NativeDatabase.prepareAsync") || error.message.includes("NativeDatabase.execAsync") || error.message.includes("NullPointerException") ); } async function openDatabase(useNewConnection: boolean): Promise { const SQLite = await loadSqlite(); const db = await SQLite.openDatabaseAsync( DATABASE_NAME, useNewConnection ? { useNewConnection: true } : {}, ); await migrateDatabase(db); return db; } export function getDatabase(): Promise { if (database) return Promise.resolve(database); if (!opening) { opening = openDatabase(false) .then((db) => { database = db; return db; }) .finally(() => { opening = null; }); } return opening; } export async function recoverDatabase(): Promise { const poisoned = database; database = null; opening = null; if (poisoned) { try { await poisoned.closeAsync(); } catch { // Handle déjà corrompu : se contenter de le lâcher, le reconnect suffit. } } const fresh = await openDatabase(true); database = fresh; return fresh; } export type DatabaseRetryDeps = { get(): Promise; recover(): Promise; }; const defaultRetryDeps: DatabaseRetryDeps = { get: getDatabase, recover: recoverDatabase, }; export async function withDatabaseRetry( run: (db: SQLiteDatabase) => Promise, deps: DatabaseRetryDeps = defaultRetryDeps, ): Promise { try { const db = await deps.get(); return await run(db); } catch (error) { if (!isBrokenConnectionError(error)) throw error; const fresh = await deps.recover(); return await run(fresh); } } export async function closeDatabase(): Promise { if (!database) return; await database.closeAsync(); database = null; opening = null; } export async function checkpointDatabase(): Promise { try { const db = await getDatabase(); await db.execAsync('PRAGMA wal_checkpoint(TRUNCATE)'); } catch { // Best-effort : ne jamais faire échouer la sync sur un checkpoint. } } type WithTransactionImpl = (work: (db: SQLiteDatabase) => Promise) => Promise; let withTransactionOverride: WithTransactionImpl | null = null; /** Test-only seam : les tests Node n'ont pas expo-sqlite, `syncRoot`/`syncDevice` * substituent leur transaction par un simple `work()`. `null` en production. */ export function __setWithTransactionForTests(impl: WithTransactionImpl | null): void { withTransactionOverride = impl; } export async function withTransaction( work: (db: SQLiteDatabase) => Promise, ): Promise { if (withTransactionOverride) return withTransactionOverride(work); return withDatabaseRetry(async (db) => { let result!: T; await db.withTransactionAsync(async () => { result = await work(db); }); return result; }); }