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
text/typescript
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();
};
}