diff --git a/ts/sql/Interface.ts b/ts/sql/Interface.ts index 3488f889a..b4775a337 100644 --- a/ts/sql/Interface.ts +++ b/ts/sql/Interface.ts @@ -911,6 +911,8 @@ export type ServerInterface = DataInterface & { allStickers: ReadonlyArray ) => Promise>; getAllBadgeImageFileLocalPaths: () => Promise>; + + runCorruptionChecks: () => void; }; export type GetRecentStoryRepliesOptionsType = { diff --git a/ts/sql/Server.ts b/ts/sql/Server.ts index 8d4b87da6..033c7bfcf 100644 --- a/ts/sql/Server.ts +++ b/ts/sql/Server.ts @@ -413,6 +413,8 @@ const dataInterface: ServerInterface = { removeKnownStickers, removeKnownDraftAttachments, getAllBadgeImageFileLocalPaths, + + runCorruptionChecks, }; export default dataInterface; @@ -698,6 +700,27 @@ async function getWritableInstance(): Promise { return globalWritableInstance; } +// This is okay to use for queries that: +// +// - Don't modify persistent tables, but create and do work in temporary +// tables +// - Integrity checks +// +function getUnsafeWritableInstance( + reason: 'only temp table use' | 'integrity check' +): Database { + // Not actually used + void reason; + + if (!globalWritableInstance) { + throw new Error( + 'getUnsafeWritableInstance: globalWritableInstance not set!' + ); + } + + return globalWritableInstance; +} + const IDENTITY_KEYS_TABLE = 'identityKeys'; async function createOrUpdateIdentityKey( data: StoredIdentityKeyType @@ -1722,9 +1745,7 @@ async function searchMessages({ }): Promise> { const { limit = conversationId ? 100 : 500 } = options ?? {}; - // We don't actually write to the database, but temporary tables below - // require write access. - const db = await getWritableInstance(); + const db = getUnsafeWritableInstance('only temp table use'); // sqlite queries with a join on a virtual table (like FTS5) are de-optimized // and can't use indices for ordering results. Instead an in-memory index of @@ -3637,7 +3658,7 @@ async function getCallHistoryGroupsCount( ): Promise { // getCallHistoryGroupDataSync creates a temporary table and thus requires // write access. - const db = await getWritableInstance(); + const db = getUnsafeWritableInstance('only temp table use'); const result = getCallHistoryGroupDataSync(db, true, filter, { limit: 0, offset: 0, @@ -3665,7 +3686,7 @@ async function getCallHistoryGroups( ): Promise> { // getCallHistoryGroupDataSync creates a temporary table and thus requires // write access. - const db = await getWritableInstance(); + const db = getUnsafeWritableInstance('only temp table use'); const groupsData = groupsDataSchema.parse( getCallHistoryGroupDataSync(db, false, filter, pagination) ); @@ -5191,6 +5212,32 @@ async function getAllBadgeImageFileLocalPaths(): Promise> { return new Set(localPaths); } +function runCorruptionChecks(): void { + const db = getUnsafeWritableInstance('integrity check'); + try { + const result = db.pragma('integrity_check'); + if (result.length === 1 && result.at(0)?.integrity_check === 'ok') { + logger.info('runCorruptionChecks: general integrity is ok'); + } else { + logger.error('runCorruptionChecks: general integrity is not ok', result); + } + } catch (error) { + logger.error( + 'runCorruptionChecks: general integrity check error', + Errors.toLogFormat(error) + ); + } + try { + db.exec("INSERT INTO messages_fts(messages_fts) VALUES('integrity-check')"); + logger.info('runCorruptionChecks: FTS5 integrity ok'); + } catch (error) { + logger.error( + 'runCorruptionChecks: FTS5 integrity check error.', + Errors.toLogFormat(error) + ); + } +} + type StoryDistributionForDatabase = Readonly< { allowsReplies: 0 | 1; diff --git a/ts/sql/errors.ts b/ts/sql/errors.ts index 29d3a828a..c7fc40a34 100644 --- a/ts/sql/errors.ts +++ b/ts/sql/errors.ts @@ -2,9 +2,9 @@ // SPDX-License-Identifier: AGPL-3.0-only export enum SqliteErrorKind { - Corrupted, - Readonly, - Unknown, + Corrupted = 'Corrupted', + Readonly = 'Readonly', + Unknown = 'Unknown', } export function parseSqliteError(error?: Error): SqliteErrorKind { diff --git a/ts/sql/main.ts b/ts/sql/main.ts index 8c8f48e72..c5f7dfc06 100644 --- a/ts/sql/main.ts +++ b/ts/sql/main.ts @@ -9,7 +9,7 @@ import { app } from 'electron'; import { strictAssert } from '../util/assert'; import { explodePromise } from '../util/explodePromise'; import type { LoggerType } from '../types/Logging'; -import { parseSqliteError, SqliteErrorKind } from './errors'; +import { SqliteErrorKind } from './errors'; import type DB from './Server'; const MIN_TRACE_DURATION = 40; @@ -54,6 +54,7 @@ export type WrappedWorkerResponse = type: 'response'; seq: number; error: string | undefined; + errorKind: SqliteErrorKind | undefined; // eslint-disable-next-line @typescript-eslint/no-explicit-any response: any; }> @@ -102,7 +103,7 @@ export class MainSQL { return; } - const { seq, error, response } = wrappedResponse; + const { seq, error, errorKind, response } = wrappedResponse; const pair = this.onResponse.get(seq); this.onResponse.delete(seq); @@ -112,7 +113,7 @@ export class MainSQL { if (error) { const errorObj = new Error(error); - this.onError(errorObj); + this.onError(errorKind ?? SqliteErrorKind.Unknown, errorObj); pair.reject(errorObj); } else { @@ -227,8 +228,7 @@ export class MainSQL { return result; } - private onError(error: Error): void { - const errorKind = parseSqliteError(error); + private onError(errorKind: SqliteErrorKind, error: Error): void { if (errorKind === SqliteErrorKind.Unknown) { return; } diff --git a/ts/sql/mainWorker.ts b/ts/sql/mainWorker.ts index 171ac90f7..ee4851a38 100644 --- a/ts/sql/mainWorker.ts +++ b/ts/sql/mainWorker.ts @@ -11,6 +11,7 @@ import type { WrappedWorkerLogEntry, } from './main'; import db from './Server'; +import { SqliteErrorKind, parseSqliteError } from './errors'; if (!parentPort) { throw new Error('Must run as a worker thread'); @@ -20,10 +21,22 @@ const port = parentPort; // eslint-disable-next-line @typescript-eslint/no-explicit-any function respond(seq: number, error: Error | undefined, response?: any) { + let errorKind: SqliteErrorKind | undefined; + let errorString: string | undefined; + if (error !== undefined) { + errorKind = parseSqliteError(error); + errorString = Errors.toLogFormat(error); + + if (errorKind === SqliteErrorKind.Corrupted) { + db.runCorruptionChecks(); + } + } + const wrappedResponse: WrappedWorkerResponse = { type: 'response', seq, - error: error === undefined ? undefined : Errors.toLogFormat(error), + error: errorString, + errorKind, response, }; port.postMessage(wrappedResponse);