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
98 changes: 96 additions & 2 deletions projects/kit/offline/src/lib/offline-local-reset.spec.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { describe, expect, it, vi } from 'vitest';
import { afterEach, describe, expect, it, vi } from 'vitest';
import {
migrateOfflineDatabaseEncryption,
recoverOfflineLocalReset,
Expand All @@ -8,6 +8,8 @@ import {
} from './offline-local-reset';

describe('offline local reset', () => {
afterEach(() => vi.restoreAllMocks());

it('persists the marker before reloading', async () => {
const events: string[] = [];
const markerStore = store({
Expand Down Expand Up @@ -306,7 +308,9 @@ describe('offline local reset', () => {
});
const sqlite = connection([]);
const failure = new Error('delete failed');
vi.mocked(await sqlite.createConnection('unused', false, 'no-encryption', 1, false)).delete.mockRejectedValueOnce(failure);
const database = await sqlite.createConnection('unused', false, 'no-encryption', 1, false);
vi.mocked(database.delete).mockRejectedValueOnce(failure);
vi.mocked(sqlite.createConnection).mockResolvedValue(database);
vi.mocked(sqlite.createConnection).mockClear();

await expect(
Expand All @@ -322,6 +326,96 @@ describe('offline local reset', () => {
).rejects.toBe(failure);

expect(markerStore.set).not.toHaveBeenCalled();
expect(database.delete).toHaveBeenCalledOnce();
});

it('retries a transient SQLite lock while deleting a legacy database', async () => {
const markerStore = store({
get: vi.fn(async () => ({ value: null })),
});
const sqlite = connection([]);
const database = await sqlite.createConnection('unused', false, 'no-encryption', 1, false);
vi.mocked(database.delete)
.mockRejectedValueOnce(new Error('Execute: execute failed rc: 5 message: database is locked'))
.mockResolvedValueOnce(undefined);
vi.mocked(sqlite.createConnection).mockResolvedValue(database).mockClear();
vi.spyOn(globalThis, 'setTimeout').mockImplementation((handler: TimerHandler) => {
if (typeof handler === 'function') handler();
return 0 as unknown as ReturnType<typeof setTimeout>;
});

await expect(
migrateOfflineDatabaseEncryption({
markerStore,
markerKey: 'product:encryption-migration',
migrationVersion: 'plaintext-v1',
sqliteConnection: sqlite,
kitCompatibleDatabaseNames: ['product-offline'],
sourceDatabaseEncryption: false,
nativePlatform: true,
}),
).resolves.toBe(true);

expect(database.delete).toHaveBeenCalledTimes(2);
expect(sqlite.closeConnection).toHaveBeenCalledOnce();
expect(markerStore.set).toHaveBeenCalledOnce();
});

it('retries a transient SQLite lock while closing a deleted database', async () => {
const markerStore = store({ get: vi.fn(async () => ({ value: null })) });
const sqlite = connection([]);
vi.mocked(sqlite.closeConnection).mockRejectedValueOnce(new Error('SQLITE_BUSY')).mockResolvedValueOnce(undefined);
vi.spyOn(globalThis, 'setTimeout').mockImplementation((handler: TimerHandler) => {
if (typeof handler === 'function') handler();
return 0 as unknown as ReturnType<typeof setTimeout>;
});

await expect(
migrateOfflineDatabaseEncryption({
markerStore,
markerKey: 'product:encryption-migration',
migrationVersion: 'plaintext-v1',
sqliteConnection: sqlite,
kitCompatibleDatabaseNames: ['product-offline'],
sourceDatabaseEncryption: false,
nativePlatform: true,
}),
).resolves.toBe(true);

expect(sqlite.closeConnection).toHaveBeenCalledTimes(2);
expect(markerStore.set).toHaveBeenCalledOnce();
});

it('stops after four transient lock failures and preserves the final error', async () => {
const markerStore = store({ get: vi.fn(async () => ({ value: null })) });
const sqlite = connection([]);
const failures = Array.from({ length: 4 }, (_, index) => new Error(`database is locked ${index + 1}`));
const database = await sqlite.createConnection('unused', false, 'no-encryption', 1, false);
vi.mocked(database.delete)
.mockRejectedValueOnce(failures[0])
.mockRejectedValueOnce(failures[1])
.mockRejectedValueOnce(failures[2])
.mockRejectedValueOnce(failures[3]);
vi.mocked(sqlite.createConnection).mockResolvedValue(database).mockClear();
vi.spyOn(globalThis, 'setTimeout').mockImplementation((handler: TimerHandler) => {
if (typeof handler === 'function') handler();
return 0 as unknown as ReturnType<typeof setTimeout>;
});

await expect(
migrateOfflineDatabaseEncryption({
markerStore,
markerKey: 'product:encryption-migration',
migrationVersion: 'plaintext-v1',
sqliteConnection: sqlite,
kitCompatibleDatabaseNames: ['product-offline'],
sourceDatabaseEncryption: false,
nativePlatform: true,
}),
).rejects.toBe(failures[3]);

expect(database.delete).toHaveBeenCalledTimes(4);
expect(markerStore.set).not.toHaveBeenCalled();
});
});

Expand Down
10 changes: 10 additions & 0 deletions projects/kit/offline/src/lib/offline-local-reset.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import {
COMMUNITY_SQLITE_READONLY,
COMMUNITY_SQLITE_VERSION,
} from './offline-community-sqlite-config';
import { isTransientSqliteLockError } from './offline-repository-concurrency';

/** Durable marker store used to request a cold-start local reset. */
export interface OfflineLocalResetMarkerStore {
Expand Down Expand Up @@ -75,6 +76,7 @@ export interface MigrateOfflineDatabaseEncryptionOptions {
}

const OFFLINE_LOCAL_RESET_REQUESTED = 'requested';
const OFFLINE_LOCAL_RESET_RETRY_DELAYS_MS = [50, 150, 300] as const;

/** Persists an explicit destructive reset request, then reloads into a cold bootstrap. */
export async function requestOfflineLocalReset(options: RequestOfflineLocalResetOptions): Promise<void> {
Expand Down Expand Up @@ -171,6 +173,14 @@ async function resolveOfflineDatabaseEncryption(
type OfflineResetOperationResult = { ok: true } | { ok: false; error: unknown };

async function settleOfflineResetOperation(operation: () => Promise<void>): Promise<OfflineResetOperationResult> {
for (const delayMs of OFFLINE_LOCAL_RESET_RETRY_DELAYS_MS) {
const result = await new Promise<void>((resolve) => resolve(operation())).then(
() => ({ ok: true }) as const,
(error: unknown) => ({ ok: false, error }) as const,
);
Comment thread
rdlabo marked this conversation as resolved.
if (result.ok || !isTransientSqliteLockError(result.error)) return result;
await new Promise<void>((resolve) => setTimeout(resolve, delayMs));
}
return new Promise<void>((resolve) => resolve(operation())).then(
() => ({ ok: true }),
(error: unknown) => ({ ok: false, error }),
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import { describe, expect, it } from 'vitest';
import { isTransientSqliteLockError, normalizeOfflineReplicaTransientWriteError } from './offline-repository-concurrency';

describe('offline repository concurrency', () => {
it('finds a transient SQLite lock through causes and aggregate errors', () => {
const locked = new Error('Execute: execute failed rc: 5 message: database is locked');
const wrapped = new Error('native operation failed', { cause: new AggregateError([new Error('SQLITE_BUSY'), locked]) });

expect(isTransientSqliteLockError(wrapped)).toBe(true);
expect(normalizeOfflineReplicaTransientWriteError(wrapped)).toMatchObject({
name: 'OfflineReplicaTransientWriteError',
reason: 'sqlite_busy',
cause: wrapped,
});
});

it('does not classify a mixed aggregate failure as a transient SQLite lock', () => {
const mixed = new AggregateError([new Error('disk full'), new Error('SQLITE_BUSY')]);

expect(isTransientSqliteLockError(mixed)).toBe(false);
expect(normalizeOfflineReplicaTransientWriteError(mixed)).toBe(mixed);
});

it('classifies an aggregate failure whose branches share one lock cause as transient', () => {
const nativeLock = new Error('Execute: execute failed rc: 5 message: database is locked');
const aggregate = new AggregateError([
new Error('delete failed', { cause: nativeLock }),
new Error('close failed', { cause: nativeLock }),
]);

expect(isTransientSqliteLockError(aggregate)).toBe(true);
expect(normalizeOfflineReplicaTransientWriteError(aggregate)).toMatchObject({
name: 'OfflineReplicaTransientWriteError',
reason: 'sqlite_locked',
cause: aggregate,
});
});

it('classifies an aggregate failure repeating one lock error as transient', () => {
const locked = new Error('SQLITE_BUSY');

expect(isTransientSqliteLockError(new AggregateError([locked, locked]))).toBe(true);
});

it('terminates safely when error causes contain a cycle', () => {
const cyclic = new Error('outer failure');
Object.defineProperty(cyclic, 'cause', { value: cyclic });

expect(isTransientSqliteLockError(cyclic)).toBe(false);
expect(normalizeOfflineReplicaTransientWriteError(cyclic)).toBe(cyclic);
});
});
22 changes: 21 additions & 1 deletion projects/kit/offline/src/lib/offline-repository-concurrency.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,24 @@ export class OfflineReplicaTransientWriteError extends Error {
}
}

function transientSqliteLockReason(error: unknown): Extract<OfflineReplicaTransientWriteReason, 'sqlite_busy' | 'sqlite_locked'> | null {
function transientSqliteLockReason(
error: unknown,
visited = new Set<object>(),
): Extract<OfflineReplicaTransientWriteReason, 'sqlite_busy' | 'sqlite_locked'> | null {
if (typeof error === 'object' && error !== null) {
if (visited.has(error)) return null;
visited.add(error);
}
if (error instanceof AggregateError) {
if (error.errors.length === 0) return null;
let aggregateReason: Extract<OfflineReplicaTransientWriteReason, 'sqlite_busy' | 'sqlite_locked'> | null = null;
for (const nested of error.errors) {
const reason = transientSqliteLockReason(nested, new Set(visited));
if (!reason) return null;
aggregateReason ??= reason;
}
return aggregateReason;
}
const code =
typeof error === 'object' && error !== null && typeof (error as { code?: unknown }).code === 'string'
? (error as { code: string }).code.toUpperCase()
Expand All @@ -38,6 +55,9 @@ function transientSqliteLockReason(error: unknown): Extract<OfflineReplicaTransi
) {
return 'sqlite_locked';
}
if (error instanceof Error && error.cause !== undefined) {
return transientSqliteLockReason(error.cause, visited);
}
return null;
}

Expand Down