From 39fbdfdc6568d98b9de612b6ca00a6686ae33dae Mon Sep 17 00:00:00 2001 From: rdlabo Date: Thu, 3 Sep 2026 09:29:03 +0900 Subject: [PATCH 1/2] fix(kit): retry transient SQLite open locks --- .../src/lib/sqlite-offline-repository.spec.ts | 26 +++++++++++++++++++ .../src/lib/sqlite-offline-repository.ts | 20 +++++++++++++- 2 files changed, 45 insertions(+), 1 deletion(-) 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..e58d992 100644 --- a/projects/kit/offline/src/lib/sqlite-offline-repository.ts +++ b/projects/kit/offline/src/lib/sqlite-offline-repository.ts @@ -120,6 +120,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 +137,23 @@ function sqliteErrorMessage(error: unknown): string { return String(error); } +function isTransientCommunitySqliteOpenError(error: unknown): boolean { + return normalizeOfflineReplicaTransientWriteError(error) instanceof OfflineReplicaTransientWriteError; +} + +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 (!isTransientCommunitySqliteOpenError(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 +254,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; })(); From 6dbcf3aeb103d2464a8ed9cd24797134e75de597 Mon Sep 17 00:00:00 2001 From: rdlabo Date: Thu, 3 Sep 2026 09:35:38 +0900 Subject: [PATCH 2/2] refactor(kit): share SQLite lock classification --- .../src/lib/offline-repository-concurrency.ts | 28 ++++++++++++++++--- .../src/lib/sqlite-offline-repository.ts | 7 ++--- 2 files changed, 26 insertions(+), 9 deletions(-) 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.ts b/projects/kit/offline/src/lib/sqlite-offline-repository.ts index e58d992..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, @@ -137,10 +138,6 @@ function sqliteErrorMessage(error: unknown): string { return String(error); } -function isTransientCommunitySqliteOpenError(error: unknown): boolean { - return normalizeOfflineReplicaTransientWriteError(error) instanceof OfflineReplicaTransientWriteError; -} - async function openCommunitySqliteDatabase(database: CommunitySqliteDatabase): Promise { for (const delayMs of COMMUNITY_SQLITE_OPEN_RETRY_DELAYS_MS) { const result = await database.open().then( @@ -148,7 +145,7 @@ async function openCommunitySqliteDatabase(database: CommunitySqliteDatabase): P (error: unknown) => ({ ok: false, error }) as const, ); if (result.ok) return; - if (!isTransientCommunitySqliteOpenError(result.error)) throw result.error; + if (!isTransientSqliteLockError(result.error)) throw result.error; await new Promise((resolve) => setTimeout(resolve, delayMs)); } await database.open();