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
5 changes: 5 additions & 0 deletions src/lib/managers/sync.manager.svelte.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { getSupportedVersion, syncStream } from '$lib/api/client';
import { App } from '@capacitor/app';
import { db } from '$lib/db/database';
import { syncAck, timetable } from '$lib/db/schema';
import { TODAY_SQL } from '$lib/db/sql';
Expand Down Expand Up @@ -98,6 +99,9 @@ class SyncManager {
}

async #startUp() {
// iOS can take the database file away while suspended, so syncs only run in the foreground.
App.addListener('pause', () => this.#retry.cancel());
App.addListener('resume', () => this.sync());
this.#enqueue(() => db.delete(timetable).where(lt(timetable.serviceDate, TODAY_SQL)));
await this.#checkVersion().catch((error) =>
appLogger.w(`Version check failed: ${describeError(error)}`),
Expand Down Expand Up @@ -131,6 +135,7 @@ class SyncManager {
await this.#reporter.watch(acks, applyAll(planSync(lines)));
appLogger.i(`Applied ${lines.length} sync lines`);
} catch (error) {
if (error instanceof SyncApplyError && error.isDatabaseUnavailable) throw error;
appLogger.w(`Incremental sync failed, running a full sync: ${describeError(error)}`);
await this.#fullSync();
}
Expand Down
12 changes: 11 additions & 1 deletion src/lib/sync/sync-errors.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import { rootCause } from '$lib/utils/logger';

export class SyncStatusError extends Error {
constructor(
readonly status: number,
Expand All @@ -9,4 +11,12 @@ export class SyncStatusError extends Error {

export class SyncLineError extends Error {}

export class SyncApplyError extends Error {}
// SQLite's text for SQLITE_CANTOPEN, which the plugin passes through on every platform.
const CANT_OPEN_MESSAGE = 'unable to open database file';

export class SyncApplyError extends Error {
/** A full sync writes to the same file, so it would fail the same way. */
get isDatabaseUnavailable(): boolean {
return `${rootCause(this)}`.includes(CANT_OPEN_MESSAGE);
}
}
25 changes: 25 additions & 0 deletions src/lib/sync/sync-reporter.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,31 @@ describe('syncFailureEvent', () => {
])('reports %s', (type, error) => {
expect(syncFailureEvent(error, [])?.type).toBe(type);
});

it('reports the SQLite error instead of the query that hit it', () => {
const sqliteError = new Error('rc: 14 message: unable to open database file');
const query = `Failed query: delete from "timetable"\nparams: ${'id,'.repeat(100)}`;
const queryError = new Error(query, { cause: sqliteError });
const error = new SyncApplyError('sync apply failed', { cause: queryError });

expect(syncFailureEvent(error, ACKS)?.payload.message).toBe(`${sqliteError}`);
});
});

describe('SyncApplyError', () => {
it.each([
[
'iOS',
'Run: Failed in runSQL : Error: prepareSQL finalize failed rc: 14 message: unable to open database file',
true,
],
['Android', 'unable to open database file (code 14 SQLITE_CANTOPEN)', true],
['a constraint', 'UNIQUE constraint failed: route.id', false],
])('knows when the database was unavailable on %s', (_, message, unavailable) => {
const error = new SyncApplyError('sync apply failed', { cause: new Error(message) });

expect(error.isDatabaseUnavailable).toBe(unavailable);
});
});

describe('SyncFailureReporter', () => {
Expand Down
5 changes: 3 additions & 2 deletions src/lib/sync/sync-reporter.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { report, type ReportEvent } from '$lib/api/client';
import { appLogger, describeError } from '$lib/utils/logger';
import { appLogger, describeError, rootCause } from '$lib/utils/logger';
import { SyncApplyError, SyncLineError, SyncStatusError } from './sync-errors';
import { SYNC_PROTOCOL_V1 } from './sync-stream';

Expand All @@ -8,7 +8,8 @@ const FIRST_SERVER_ERROR_STATUS = 500;

/** Returns null for failures not worth reporting, like being offline or the gateway being down. */
export function syncFailureEvent(error: unknown, acks: string[]): ReportEvent | null {
const payload = { protocol: SYNC_PROTOCOL_V1, acks, message: describeError(error) };
// The gateway truncates the message, and the failed query's params would push the cause out.
const payload = { protocol: SYNC_PROTOCOL_V1, acks, message: `${rootCause(error)}` };

if (error instanceof SyncStatusError && error.status < FIRST_SERVER_ERROR_STATUS) {
return { type: 'SyncFailedHTTPStatusV1', payload: { ...payload, status: error.status } };
Expand Down
5 changes: 5 additions & 0 deletions src/lib/utils/logger.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,3 +45,8 @@ export const appLogger = new Logger('APP', logLevel);
export function describeError(error: unknown): string {
return error instanceof Error && error.cause ? `${error} (cause: ${error.cause})` : `${error}`;
}

export function rootCause(error: unknown): unknown {
while (error instanceof Error && error.cause) error = error.cause;
return error;
}
Loading