UNPKG

stream-chat-react-native-core

Version:

The official React Native and Expo components for Stream Chat, a service for building chat applications

413 lines (364 loc) 14.8 kB
import type { _InternalDB, OPSQLiteProxy } from '@op-engineering/op-sqlite'; let sqlite: OPSQLiteProxy; try { sqlite = require('@op-engineering/op-sqlite'); } catch (e) { // We want to throw the original error when remote debugger (e.g. Chrome) is enabled. // SQLite can only be used when synchronous method invocations (JSI) are possible. // e.g on-device debugger (e.g. Flipper). const isRemoteDebuggerError = e instanceof Error && e.message.includes('Failed to install'); if (isRemoteDebuggerError) { throw e; } // Reaching here will mean that QuickSQLite is not installed for one of the reasons // 1. Running on regular expo, where we don't support offline storage yet. // 2. Offline support is disabled, in which case this library is not installed. } import { Logger } from 'stream-chat'; import { DB_LOCATION, DB_NAME } from './constants'; import { tables } from './schema'; import { createCreateTableQuery } from './sqlite-utils/createCreateTableQuery'; import type { PreparedBatchQueries, PreparedQueries, Scalar, Table } from './types'; /** * Why the offline database could not be opened. The first two only arise when * {@link SqliteClient.getEncryptionKey} is set; `OFFLINE_DB_UNREADABLE` can also mean * plain corruption, or a database left behind from the other encryption mode. */ export type SqliteClientErrorCode = | 'SQLCIPHER_BUILD_MISSING' | 'ENCRYPTION_KEY_UNAVAILABLE' | 'OFFLINE_DB_UNREADABLE'; export class SqliteClientError extends Error { public readonly code: SqliteClientErrorCode; constructor(code: SqliteClientErrorCode, message: string, options?: { cause?: unknown }) { super(message); this.name = 'SqliteClientError'; this.code = code; // Assigned here rather than passed through `super(message, { cause })` because // Hermes does not reliably honour the ErrorOptions overload. this.cause = options?.cause; } } /** * SqliteClient takes care of any direct interaction with sqlite. * This way usage @op-engineering/op-sqlite package is scoped to a single class/file. */ export class SqliteClient { static dbVersion = 16; static dbName = DB_NAME; static dbLocation = DB_LOCATION; static logger: Logger | undefined; static db: _InternalDB | undefined; /** * Supplies the SQLCipher key the offline database is opened with; `undefined` * opens it unencrypted, which is the default. The key must be stable for the * lifetime of the database file - there is no rekey path, so a database this key * cannot read raises `OFFLINE_DB_UNREADABLE` on the first page read. The file is * left untouched; recovery is `SqliteClient.deleteDatabase()` and a re-mount. */ static getEncryptionKey: (() => Promise<string | undefined>) | undefined; /** Key resolved by {@link preflightEncryption}, consumed by the next {@link openDB}. */ private static preflightedKey: string | undefined; /** Busy/disk/memory failures. Checked first: wiping over these destroys a good db. */ private static TRANSIENT_ERROR = /database is locked|SQLITE_BUSY|SQLITE_LOCKED|disk i\/o|SQLITE_IOERR|unable to open|SQLITE_CANTOPEN|out of memory|readonly/i; /** * The bytes on disk cannot be read with the key we have: wrong/rotated key, * plaintext-encrypted mismatch or corruption. SQLCipher has no decrypt specific * code and overloads NOTADB (26), occasionally CORRUPT (11). */ private static UNREADABLE_ERROR = /not a database|file is encrypted|malformed|disk image is malformed|SQLite (?:error )?code:?\s*(?:26|11)\b|NOTADB|SQLITE_CORRUPT/i; static getDbVersion = () => SqliteClient.dbVersion; // Force a specific db version. This is mainly useful for testsuit. static setDbVersion = (version: number) => (SqliteClient.dbVersion = version); /** * Records and re-throws. Deliberately does not write to the console: the error is * thrown, so logging it here would duplicate whatever the caller's error boundary * reports - and in dev React already logs every boundary-caught error, which is what * LogBox turns red. */ private static recordError = (e: SqliteClientError) => { SqliteClient.logger?.('error', e.message, { tag: e.code }); throw e; }; /** * Resolves the encryption key without opening the database, so callers can decide * whether to attach an `OfflineDB` at all. Parts of the client write through * `client.offlineDb` without checking that it initialized (`queryChannels` upserts * into it), so attaching one we cannot open turns those writes into rejections. * * Throws {@link SqliteClientError}. The key is handed to the next * {@link openDB} rather than read from `getEncryptionKey` twice. */ static preflightEncryption = async () => { try { SqliteClient.preflightedKey = await SqliteClient.resolveEncryptionKey(); } catch (e) { if (e instanceof SqliteClientError) { SqliteClient.recordError(e); } throw e; } }; /** * The key to open with, or `undefined` when the database is meant to be * unencrypted. Throws rather than silently falling back to an unencrypted * database, which would hand an integration that asked for encryption a plaintext * cache of its users' messages. */ private static resolveEncryptionKey = async () => { const { getEncryptionKey } = SqliteClient; if (!getEncryptionKey) { return undefined; } // A non-SQLCipher build accepts `encryptionKey` at the JSI boundary and then // drops it - plaintext database, no error anywhere. `isSQLCipher` has existed // since op-sqlite 9, well below the peer floor, so the typeof check is not really // necessary but we'll keep it in case something changes in the future so that // we at least have a clearer error. if (sqlite === undefined) { throw new SqliteClientError( 'SQLCIPHER_BUILD_MISSING', 'An offline database encryption key was provided but "@op-engineering/op-sqlite" ' + 'is not installed.', ); } if (typeof sqlite.isSQLCipher !== 'function' || !sqlite.isSQLCipher()) { throw new SqliteClientError( 'SQLCIPHER_BUILD_MISSING', 'An offline database encryption key was provided but @op-engineering/op-sqlite was ' + 'not built with SQLCipher, so the key would be silently ignored and the offline ' + 'database written in plaintext. Add { "op-sqlite": { "sqlcipher": true } } to your ' + "application's package.json and rebuild, or stop providing a key.", ); } let encryptionKey: string | undefined; try { encryptionKey = await getEncryptionKey(); } catch (error) { throw new SqliteClientError( 'ENCRYPTION_KEY_UNAVAILABLE', 'The offline database encryption key getter threw, so the database cannot be opened.', { cause: error }, ); } // Not being handed a key is not the same as being handed the wrong one, so a locked // keychain must not cost us a database we can still read later. if (!encryptionKey) { throw new SqliteClientError( 'ENCRYPTION_KEY_UNAVAILABLE', 'The offline database encryption key getter resolved without a key, so the database ' + 'cannot be opened.', ); } return encryptionKey; }; static openDB = async () => { try { if (sqlite === undefined) { throw new Error( 'Please install "@op-engineering/op-sqlite" package to enable offline support', ); } const encryptionKey = SqliteClient.preflightedKey ?? (await SqliteClient.resolveEncryptionKey()); SqliteClient.preflightedKey = undefined; SqliteClient.db = sqlite.open({ location: SqliteClient.dbLocation, name: SqliteClient.dbName, ...(encryptionKey ? { encryptionKey } : {}), }); // Note: this will not fail on an encryption key mismatch, as we do not read // any pages, but rather look at a connection level flag. The first failure // is going to be whatever actually reads something, which is going to be // the user_version read in initializeDatabase. await SqliteClient.db?.execute('PRAGMA foreign_keys = ON', []); } catch (e) { if (e instanceof SqliteClientError) { throw e; } SqliteClient.logger?.('error', `Error opening database ${SqliteClient.dbName}`, { error: e, }); console.error(`Error opening database ${SqliteClient.dbName}: ${e}`); } }; static closeDB = () => { try { if (!SqliteClient.db) { throw new Error('DB is not open or initialized.'); } SqliteClient.db.close(); SqliteClient.db = undefined; } catch (e) { SqliteClient.logger?.('error', `Error closing database ${SqliteClient.dbName}`, { error: e, }); console.error(`Error closing database ${SqliteClient.dbName}: ${e}`); } }; static executeSqlBatch = async (queries: PreparedBatchQueries[]) => { if (!queries || !queries.length) { return; } try { if (!SqliteClient.db) { throw new Error('DB is not open or initialized.'); } // This is a workaround to make the executeBatch method work. // It expects an empty array as the second argument in the individual queries if nothing present. // Discussion - https://discord.com/channels/1301463257722126357/1324262993780932688/1330846910596251711 const finalQueries = queries.map((query) => { if (query.length === 1) { // @ts-ignore query.push([]); } return query; }); await SqliteClient.db.executeBatch(finalQueries); } catch (e) { SqliteClient.logger?.('error', 'SqlBatch queries failed', { error: e, queries, }); throw new Error(`Queries failed: ${e}`); } }; static executeSql = async (query: string, params?: Scalar[]) => { try { if (!SqliteClient.db) { throw new Error('DB is not open or initialized.'); } const { rows } = await SqliteClient.db.execute(query, params); return rows ? (rows as Record<string, string>[]) : []; } catch (e) { SqliteClient.logger?.('error', 'Sql single query failed', { error: e, query, }); throw new Error(`Query failed: ${e}: `); } }; static dropTables = async () => { const queries: PreparedQueries[] = Object.keys(tables).map((table) => [ `DROP TABLE IF EXISTS ${table}`, [], ]); SqliteClient.logger?.('info', 'Dropping tables', { tables: Object.keys(tables), }); await SqliteClient.executeSqlBatch(queries); }; static deleteDatabase = () => { SqliteClient.logger?.('info', 'deleteDatabase', { dbLocation: SqliteClient.dbLocation, dbname: SqliteClient.dbName, }); try { if (!SqliteClient.db) { throw new Error('DB is not open or initialized.'); } SqliteClient.db.delete(); } catch (e) { SqliteClient.logger?.('error', 'Error deleting DB', { dbLocation: SqliteClient.dbLocation, dbname: SqliteClient.dbName, error: e, }); throw new Error(`Error deleting DB: ${e}`); } return true; }; /** * Whether the file cannot be read with the key we have, as opposed to being * temporarily unavailable (busy, locked, disk). Works off message text because * op-sqlite rejects with a plain Error and this class re-wraps those messages, so * no numeric code survives. Drives `OFFLINE_DB_UNREADABLE`. */ static isUnreadableDbError = (e: unknown) => { const message = String((e as Error)?.message ?? e); if (SqliteClient.TRANSIENT_ERROR.test(message)) { return false; } return SqliteClient.UNREADABLE_ERROR.test(message); }; static initializeDatabase = async (): Promise<boolean> => { try { await SqliteClient.openDB(); const version = await SqliteClient.getUserPragmaVersion(); if (version !== SqliteClient.dbVersion) { SqliteClient.logger?.('info', 'DB version mismatch'); await SqliteClient.dropTables(); await SqliteClient.updateUserPragmaVersion(SqliteClient.dbVersion); } SqliteClient.logger?.('info', 'create tables if not exists', { tables: Object.keys(tables), }); const q = (Object.keys(tables) as Table[]).reduce<PreparedQueries[]>( (queriesSoFar, tableName) => { queriesSoFar.push(...createCreateTableQuery(tableName)); return queriesSoFar; }, [], ); await SqliteClient.executeSqlBatch(q); return true; } catch (e) { if (e instanceof SqliteClientError) { SqliteClient.recordError(e); } if (SqliteClient.isUnreadableDbError(e)) { SqliteClient.recordError( new SqliteClientError( 'OFFLINE_DB_UNREADABLE', 'The offline database exists but could not be read. Usually the encryption ' + 'key changed, or encryption was turned on or off while a database from ' + 'the other mode was still on disk. Delete it with ' + 'SqliteClient.deleteDatabase() and re-mount to rebuild from the server - ' + 'everything in it is a cache, except queued offline actions, which are lost.', { cause: e }, ), ); } console.log('Error initializing DB', e); SqliteClient.logger?.('error', 'Error initializing DB', { dbLocation: SqliteClient.dbLocation, dbname: SqliteClient.dbName, error: e, }); return false; } }; static updateUserPragmaVersion = async (version: number) => { SqliteClient.logger?.('info', `updateUserPragmaVersion to ${version}`); if (!SqliteClient.db) { throw new Error('DB is not open or initialized.'); } await SqliteClient.db.execute(`PRAGMA user_version = ${version}`, []); }; static getUserPragmaVersion = async () => { try { if (!SqliteClient.db) { throw new Error('DB is not open or initialized.'); } const { rows } = await SqliteClient.db.execute('PRAGMA user_version', []); const result = rows ? rows : []; SqliteClient.logger?.('info', 'getUserPragmaVersion', { result, }); return result[0].user_version as number; } catch (e) { console.log('Error getting user_version', e); throw new Error(`Querying for user_version failed: ${e}`); } }; static resetDB = async () => { SqliteClient.logger?.('info', 'resetDB'); if (SqliteClient.db) { await SqliteClient.dropTables(); SqliteClient.closeDB(); } await SqliteClient.initializeDatabase(); }; }