diff --git a/projects/kit/offline/src/lib/offline-repository-concurrency.ts b/projects/kit/offline/src/lib/offline-repository-concurrency.ts index 8a8b4a3..06f49d6 100644 --- a/projects/kit/offline/src/lib/offline-repository-concurrency.ts +++ b/projects/kit/offline/src/lib/offline-repository-concurrency.ts @@ -20,8 +20,7 @@ export class OfflineReplicaTransientWriteError extends Error { } } -export function normalizeOfflineReplicaTransientWriteError(error: unknown): unknown { - if (error instanceof OfflineReplicaTransientWriteError) return error; +function transientSqliteLockReason(error: unknown): Extract | null { const code = typeof error === 'object' && error !== null && typeof (error as { code?: unknown }).code === 'string' ? (error as { code: string }).code.toUpperCase() @@ -29,7 +28,7 @@ export function normalizeOfflineReplicaTransientWriteError(error: unknown): unkn const message = error instanceof Error ? error.message : typeof error === 'string' ? error : ''; const normalizedMessage = message.toUpperCase(); if (code.includes('SQLITE_BUSY') || normalizedMessage.includes('SQLITE_BUSY')) { - return new OfflineReplicaTransientWriteError('sqlite_busy', message || 'SQLite is busy.', { cause: error }); + return 'sqlite_busy'; } if ( code.includes('SQLITE_LOCKED') || @@ -37,7 +36,28 @@ export function normalizeOfflineReplicaTransientWriteError(error: unknown): unkn message.includes('database is locked') || message.includes('database table is locked') ) { - return new OfflineReplicaTransientWriteError('sqlite_locked', message || 'SQLite database is locked.', { cause: error }); + return 'sqlite_locked'; + } + return null; +} + +/** Returns whether SQLite reported a transient busy/locked condition in any supported error shape. */ +export function isTransientSqliteLockError(error: unknown): boolean { + return error instanceof OfflineReplicaTransientWriteError + ? error.reason === 'sqlite_busy' || error.reason === 'sqlite_locked' + : transientSqliteLockReason(error) !== null; +} + +export function normalizeOfflineReplicaTransientWriteError(error: unknown): unknown { + if (error instanceof OfflineReplicaTransientWriteError) return error; + const reason = transientSqliteLockReason(error); + if (reason) { + const message = error instanceof Error ? error.message : typeof error === 'string' ? error : ''; + return new OfflineReplicaTransientWriteError( + reason, + message || (reason === 'sqlite_busy' ? 'SQLite is busy.' : 'SQLite database is locked.'), + { cause: error }, + ); } return error; } diff --git a/projects/kit/offline/src/lib/sqlite-offline-repository.spec.ts b/projects/kit/offline/src/lib/sqlite-offline-repository.spec.ts index e30f7e8..8134175 100644 --- a/projects/kit/offline/src/lib/sqlite-offline-repository.spec.ts +++ b/projects/kit/offline/src/lib/sqlite-offline-repository.spec.ts @@ -562,6 +562,32 @@ describe('createCommunitySqliteDriver', () => { expect(connection.closeConnection).not.toHaveBeenCalled(); }); + it('retries a transient native database lock while setting the journal mode', async () => { + const database = createDatabase(); + vi.mocked(database.open) + .mockRejectedValueOnce(new Error('Open: database is locked (code 5), while compiling: PRAGMA journal_mode')) + .mockResolvedValueOnce(undefined); + const connection: CommunitySqliteConnection = { + isSecretStored: vi.fn(async () => ({ result: true })), + setEncryptionSecret: vi.fn(async () => undefined), + createConnection: vi.fn(async () => database), + closeConnection: vi.fn(async () => undefined), + }; + const timeout = vi.spyOn(globalThis, 'setTimeout').mockImplementation((handler: TimerHandler) => { + if (typeof handler === 'function') handler(); + return 0 as unknown as ReturnType; + }); + + await expect(createCommunitySqliteDriver(connection).open({ databaseName: 'product-offline' })).resolves.toEqual({ + databaseId: 'product-offline', + }); + timeout.mockRestore(); + + expect(database.open).toHaveBeenCalledTimes(2); + expect(connection.createConnection).toHaveBeenCalledOnce(); + expect(connection.closeConnection).not.toHaveBeenCalled(); + }); + it('recovers the connection orphaned when database open fails before a later retry', async () => { const failedDatabase = createDatabase(); vi.mocked(failedDatabase.open).mockRejectedValueOnce(new Error('database open failed')); diff --git a/projects/kit/offline/src/lib/sqlite-offline-repository.ts b/projects/kit/offline/src/lib/sqlite-offline-repository.ts index 7ad6c11..a7d3d5b 100644 --- a/projects/kit/offline/src/lib/sqlite-offline-repository.ts +++ b/projects/kit/offline/src/lib/sqlite-offline-repository.ts @@ -45,6 +45,7 @@ import { type OfflineScope, } from './offline-repository'; import { + isTransientSqliteLockError, normalizeOfflineReplicaTransientWriteError, OFFLINE_REPOSITORY_ATOMIC_MUTATION, OfflineReplicaTransientWriteError, @@ -120,6 +121,7 @@ export async function createRandomOfflineEncryptionKey(): Promise { } const settledSqliteOperation = async (): Promise => undefined; +const COMMUNITY_SQLITE_OPEN_RETRY_DELAYS_MS = [50, 150, 300] as const; interface CachedCommunitySqliteDriver { driver: CommunitySqliteDriver; @@ -136,6 +138,19 @@ function sqliteErrorMessage(error: unknown): string { return String(error); } +async function openCommunitySqliteDatabase(database: CommunitySqliteDatabase): Promise { + for (const delayMs of COMMUNITY_SQLITE_OPEN_RETRY_DELAYS_MS) { + const result = await database.open().then( + () => ({ ok: true }) as const, + (error: unknown) => ({ ok: false, error }) as const, + ); + if (result.ok) return; + if (!isTransientSqliteLockError(result.error)) throw result.error; + await new Promise((resolve) => setTimeout(resolve, delayMs)); + } + await database.open(); +} + function normalizeCommunitySqliteDatabaseName(database: string): string { return database.endsWith('.db') ? database.slice(0, -3) : database; } @@ -236,7 +251,7 @@ export function createCommunitySqliteDriver( const opening = (async () => { await ensureSecret(createEncryptionKey); const value = await createReloadSafeCommunitySqliteDatabase(connection, databaseName, encrypted); - await value.open(); + await openCommunitySqliteDatabase(value); databases.set(databaseKey, value); return value; })();