add test + fix sqlite pb
This commit is contained in:
+1
-1
@@ -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",
|
||||||
|
|||||||
@@ -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);
|
||||||
database = db;
|
|
||||||
return db;
|
return db;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function getDatabase(): Promise<SQLiteDatabase> {
|
||||||
|
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<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;
|
|
||||||
}
|
}
|
||||||
@@ -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)),
|
||||||
|
};
|
||||||
@@ -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),
|
||||||
|
);
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user