add test + fix sqlite pb

This commit is contained in:
m
2026-09-10 22:08:27 +02:00
parent 4e2d92a80e
commit 6332e9cac4
4 changed files with 216 additions and 13 deletions
+1 -1
View File
@@ -34,7 +34,7 @@
"android": "expo run:android", "android": "expo run:android",
"ios": "expo run:ios", "ios": "expo run:ios",
"web": "expo start --web", "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:migrations": "tsx --test tests/migrations.test.ts",
"test:api": "tsx --test tests/apiClient.test.ts", "test:api": "tsx --test tests/apiClient.test.ts",
"test:sync": "tsx --test tests/syncOutbox.test.ts", "test:sync": "tsx --test tests/syncOutbox.test.ts",
+79 -4
View File
@@ -3,34 +3,109 @@ import { migrateDatabase } from './migrations';
import { DATABASE_NAME } from './schema'; import { DATABASE_NAME } from './schema';
let database: SQLiteDatabase | null = null; let database: SQLiteDatabase | null = null;
let opening: Promise<SQLiteDatabase> | null = null;
async function loadSqlite(): Promise<typeof import('expo-sqlite')> { async function loadSqlite(): Promise<typeof import('expo-sqlite')> {
return import('expo-sqlite'); return import('expo-sqlite');
} }
export async function getDatabase(): Promise<SQLiteDatabase> { /**
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<SQLiteDatabase> {
const SQLite = await loadSqlite(); const SQLite = await loadSqlite();
const db = await SQLite.openDatabaseAsync(DATABASE_NAME); const db = await SQLite.openDatabaseAsync(
DATABASE_NAME,
useNewConnection ? { useNewConnection: true } : {},
);
await migrateDatabase(db); await migrateDatabase(db);
return db;
}
export function getDatabase(): Promise<SQLiteDatabase> {
if (database) return Promise.resolve(database);
if (!opening) {
opening = openDatabase(false)
.then((db) => {
database = db; database = db;
return db; return db;
})
.finally(() => {
opening = null;
});
}
return opening;
}
export async function recoverDatabase(): Promise<SQLiteDatabase> {
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<SQLiteDatabase>;
recover(): Promise<SQLiteDatabase>;
};
const defaultRetryDeps: DatabaseRetryDeps = {
get: getDatabase,
recover: recoverDatabase,
};
export async function withDatabaseRetry<T>(
run: (db: SQLiteDatabase) => Promise<T>,
deps: DatabaseRetryDeps = defaultRetryDeps,
): Promise<T> {
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<void> { export async function closeDatabase(): Promise<void> {
if (!database) return; if (!database) return;
await database.closeAsync(); await database.closeAsync();
database = null; database = null;
opening = null;
} }
export async function withTransaction<T>( export async function withTransaction<T>(
work: (db: SQLiteDatabase) => Promise<T>, work: (db: SQLiteDatabase) => Promise<T>,
): Promise<T> { ): Promise<T> {
const db = await getDatabase(); return withDatabaseRetry(async (db) => {
let result!: T; let result!: T;
await db.withTransactionAsync(async () => { await db.withTransactionAsync(async () => {
result = await work(db); result = await work(db);
}); });
return result; return result;
});
} }
+13 -2
View File
@@ -1,4 +1,4 @@
import { getDatabase } from './client'; import { getDatabase, withDatabaseRetry } from './client';
import type { SQLiteBindValue } from 'expo-sqlite'; import type { SQLiteBindValue } from 'expo-sqlite';
export type DbSession = { export type DbSession = {
@@ -14,5 +14,16 @@ export function __setDbForTests(db: DbSession | null): void {
} }
export async function getSession(): Promise<DbSession> { export async function getSession(): Promise<DbSession> {
return override ?? (await getDatabase()); 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)),
};
+117
View File
@@ -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),
);
});