diff --git a/src/lib/managers/sync.manager.svelte.ts b/src/lib/managers/sync.manager.svelte.ts index ef31808..4dc973d 100644 --- a/src/lib/managers/sync.manager.svelte.ts +++ b/src/lib/managers/sync.manager.svelte.ts @@ -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'; @@ -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)}`), @@ -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(); } diff --git a/src/lib/sync/sync-errors.ts b/src/lib/sync/sync-errors.ts index 20faec8..36470b6 100644 --- a/src/lib/sync/sync-errors.ts +++ b/src/lib/sync/sync-errors.ts @@ -1,3 +1,5 @@ +import { rootCause } from '$lib/utils/logger'; + export class SyncStatusError extends Error { constructor( readonly status: number, @@ -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); + } +} diff --git a/src/lib/sync/sync-reporter.test.ts b/src/lib/sync/sync-reporter.test.ts index 5838ad4..2c7745d 100644 --- a/src/lib/sync/sync-reporter.test.ts +++ b/src/lib/sync/sync-reporter.test.ts @@ -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', () => { diff --git a/src/lib/sync/sync-reporter.ts b/src/lib/sync/sync-reporter.ts index d0f2b55..5fff819 100644 --- a/src/lib/sync/sync-reporter.ts +++ b/src/lib/sync/sync-reporter.ts @@ -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'; @@ -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 } }; diff --git a/src/lib/utils/logger.ts b/src/lib/utils/logger.ts index bf066d3..a933ecb 100644 --- a/src/lib/utils/logger.ts +++ b/src/lib/utils/logger.ts @@ -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; +}