From 6332e9cac4ba920b42b27efa4fac626ad323d155 Mon Sep 17 00:00:00 2001 From: m Date: Thu, 10 Sep 2026 22:08:27 +0200 Subject: [PATCH] add test + fix sqlite pb --- mobile/package.json | 2 +- mobile/services/db/client.ts | 93 ++++++++++++++++++++++++--- mobile/services/db/session.ts | 17 ++++- mobile/tests/dbClient.test.ts | 117 ++++++++++++++++++++++++++++++++++ 4 files changed, 216 insertions(+), 13 deletions(-) create mode 100644 mobile/tests/dbClient.test.ts diff --git a/mobile/package.json b/mobile/package.json index 21615e1..5710245 100644 --- a/mobile/package.json +++ b/mobile/package.json @@ -34,7 +34,7 @@ "android": "expo run:android", "ios": "expo run:ios", "web": "expo start --web", - "test:db": "tsx --test tests/migrations.test.ts tests/repositories.test.ts", + "test:db": "tsx --test tests/migrations.test.ts tests/repositories.test.ts tests/dbClient.test.ts", "test:migrations": "tsx --test tests/migrations.test.ts", "test:api": "tsx --test tests/apiClient.test.ts", "test:sync": "tsx --test tests/syncOutbox.test.ts", diff --git a/mobile/services/db/client.ts b/mobile/services/db/client.ts index 34d7b1e..5bca1b4 100644 --- a/mobile/services/db/client.ts +++ b/mobile/services/db/client.ts @@ -3,34 +3,109 @@ 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'); } -export async function getDatabase(): Promise { - if (database) return database; +/** + * 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); + const db = await SQLite.openDatabaseAsync( + DATABASE_NAME, + useNewConnection ? { useNewConnection: true } : {}, + ); await migrateDatabase(db); - database = 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 withTransaction( work: (db: SQLiteDatabase) => Promise, ): Promise { - const db = await getDatabase(); - let result!: T; - await db.withTransactionAsync(async () => { - result = await work(db); + return withDatabaseRetry(async (db) => { + let result!: T; + await db.withTransactionAsync(async () => { + result = await work(db); + }); + return result; }); - return result; } \ No newline at end of file diff --git a/mobile/services/db/session.ts b/mobile/services/db/session.ts index 698659f..1aeb050 100644 --- a/mobile/services/db/session.ts +++ b/mobile/services/db/session.ts @@ -1,4 +1,4 @@ -import { getDatabase } from './client'; +import { getDatabase, withDatabaseRetry } from './client'; import type { SQLiteBindValue } from 'expo-sqlite'; export type DbSession = { @@ -14,5 +14,16 @@ export function __setDbForTests(db: DbSession | null): void { } export async function getSession(): Promise { - return override ?? (await getDatabase()); -} \ No newline at end of file + if (override) return override; + await getDatabase(); + return liveSession; +} + +const liveSession: DbSession = { + runAsync: (sql, ...params) => + withDatabaseRetry((db) => db.runAsync(sql, ...params)), + getFirstAsync: (sql, ...params) => + withDatabaseRetry((db) => db.getFirstAsync(sql, ...params)), + getAllAsync: (sql, ...params) => + withDatabaseRetry((db) => db.getAllAsync(sql, ...params)), +}; \ No newline at end of file diff --git a/mobile/tests/dbClient.test.ts b/mobile/tests/dbClient.test.ts new file mode 100644 index 0000000..c45a284 --- /dev/null +++ b/mobile/tests/dbClient.test.ts @@ -0,0 +1,117 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { + isBrokenConnectionError, + withDatabaseRetry, + type DatabaseRetryDeps, +} from '../services/db/client'; +import type { SQLiteDatabase } from 'expo-sqlite'; + +function brokenConnectionError(): Error { + return new Error( + "Call to function 'NativeDatabase.prepareAsync' has been rejected.\n→ Caused by: java.lang.NullPointerException: java.lang.NullPointerException", + ); +} + +test('isBrokenConnectionError recognizes the expo-sqlite Android NPE signature', () => { + assert.equal(isBrokenConnectionError(brokenConnectionError()), true); + assert.equal( + isBrokenConnectionError( + new Error( + "Call to function 'NativeDatabase.execAsync' has been rejected.\n→ Caused by: java.lang.NullPointerException: java.lang.NullPointerException", + ), + ), + true, + ); +}); + +test('isBrokenConnectionError ignores regular and non-Error values', () => { + assert.equal(isBrokenConnectionError(new Error('disk I/O error')), false); + assert.equal(isBrokenConnectionError(null), false); + assert.equal(isBrokenConnectionError('NativeDatabase.prepareAsync'), false); +}); + +test('withDatabaseRetry reconnects once on a dead connection and retries', async () => { + let current: SQLiteDatabase = { + runAsync: async () => { + throw brokenConnectionError(); + }, + } as unknown as SQLiteDatabase; + let getCalls = 0; + let recoverCalls = 0; + + const deps: DatabaseRetryDeps = { + get: async () => { + getCalls++; + return current; + }, + recover: async () => { + recoverCalls++; + current = { + runAsync: async () => 42 as never, + } as unknown as SQLiteDatabase; + return current; + }, + }; + + const result = await withDatabaseRetry(async (db) => db.runAsync('SELECT 1'), deps); + assert.equal(result, 42); + assert.equal(recoverCalls, 1); + assert.equal(getCalls, 1); +}); + +test('withDatabaseRetry recovers when the open itself rejects with the NPE signature', async () => { + let recoverCalls = 0; + const deps: DatabaseRetryDeps = { + get: async () => { + throw brokenConnectionError(); + }, + recover: async () => { + recoverCalls++; + return { + runAsync: async () => 42 as never, + } as unknown as SQLiteDatabase; + }, + }; + + const result = await withDatabaseRetry(async (db) => db.runAsync('SELECT 1'), deps); + assert.equal(result, 42); + assert.equal(recoverCalls, 1); +}); + +test('withDatabaseRetry does not recover on a regular database error', async () => { + let recoverCalls = 0; + const deps: DatabaseRetryDeps = { + get: async () => + ({ + runAsync: async () => { + throw new Error('disk I/O error'); + }, + }) as unknown as SQLiteDatabase, + recover: async () => { + recoverCalls++; + return {} as SQLiteDatabase; + }, + }; + + await assert.rejects(() => withDatabaseRetry(async (db) => db.runAsync('SELECT 1'), deps), { + message: 'disk I/O error', + }); + assert.equal(recoverCalls, 0); +}); + +test('withDatabaseRetry converges on a broken retry (recovery does not loop forever)', async () => { + const deps: DatabaseRetryDeps = { + get: async () => ({} as SQLiteDatabase), + recover: async () => ({} as SQLiteDatabase), + }; + + await assert.rejects( + () => + withDatabaseRetry(async () => { + throw brokenConnectionError(); + }, deps), + (error: unknown) => + error instanceof Error && isBrokenConnectionError(error), + ); +}); \ No newline at end of file