Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 24 additions & 4 deletions projects/kit/offline/src/lib/offline-repository-concurrency.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,24 +20,44 @@ export class OfflineReplicaTransientWriteError extends Error {
}
}

export function normalizeOfflineReplicaTransientWriteError(error: unknown): unknown {
if (error instanceof OfflineReplicaTransientWriteError) return error;
function transientSqliteLockReason(error: unknown): Extract<OfflineReplicaTransientWriteReason, 'sqlite_busy' | 'sqlite_locked'> | null {
const code =
typeof error === 'object' && error !== null && typeof (error as { code?: unknown }).code === 'string'
? (error as { code: string }).code.toUpperCase()
: '';
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') ||
normalizedMessage.includes('SQLITE_LOCKED') ||
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;
}
26 changes: 26 additions & 0 deletions projects/kit/offline/src/lib/sqlite-offline-repository.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<typeof setTimeout>;
});

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'));
Expand Down
17 changes: 16 additions & 1 deletion projects/kit/offline/src/lib/sqlite-offline-repository.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ import {
type OfflineScope,
} from './offline-repository';
import {
isTransientSqliteLockError,
normalizeOfflineReplicaTransientWriteError,
OFFLINE_REPOSITORY_ATOMIC_MUTATION,
OfflineReplicaTransientWriteError,
Expand Down Expand Up @@ -120,6 +121,7 @@ export async function createRandomOfflineEncryptionKey(): Promise<string> {
}

const settledSqliteOperation = async (): Promise<void> => undefined;
const COMMUNITY_SQLITE_OPEN_RETRY_DELAYS_MS = [50, 150, 300] as const;

interface CachedCommunitySqliteDriver {
driver: CommunitySqliteDriver;
Expand All @@ -136,6 +138,19 @@ function sqliteErrorMessage(error: unknown): string {
return String(error);
}

async function openCommunitySqliteDatabase(database: CommunitySqliteDatabase): Promise<void> {
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<void>((resolve) => setTimeout(resolve, delayMs));
}
await database.open();
}

function normalizeCommunitySqliteDatabaseName(database: string): string {
return database.endsWith('.db') ? database.slice(0, -3) : database;
}
Expand Down Expand Up @@ -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;
})();
Expand Down