stream-chat-react-native-core 8.13.18 → 8.14.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/lib/commonjs/components/ChannelList/hooks/usePaginatedChannels.js +4 -1
- package/lib/commonjs/components/ChannelList/hooks/usePaginatedChannels.js.map +1 -1
- package/lib/commonjs/components/Chat/Chat.js +10 -23
- package/lib/commonjs/components/Chat/Chat.js.map +1 -1
- package/lib/commonjs/components/Chat/hooks/useInitializeOfflineDb.js +64 -0
- package/lib/commonjs/components/Chat/hooks/useInitializeOfflineDb.js.map +1 -0
- package/lib/commonjs/index.js +7 -0
- package/lib/commonjs/index.js.map +1 -1
- package/lib/commonjs/mock-builders/DB/mock.js +13 -1
- package/lib/commonjs/mock-builders/DB/mock.js.map +1 -1
- package/lib/commonjs/store/OfflineDB.js +15 -2
- package/lib/commonjs/store/OfflineDB.js.map +1 -1
- package/lib/commonjs/store/SqliteClient.js +94 -10
- package/lib/commonjs/store/SqliteClient.js.map +1 -1
- package/lib/commonjs/test-utils/BetterSqlite.js +3 -2
- package/lib/commonjs/test-utils/BetterSqlite.js.map +1 -1
- package/lib/commonjs/version.json +1 -1
- package/lib/module/components/ChannelList/hooks/usePaginatedChannels.js +4 -1
- package/lib/module/components/ChannelList/hooks/usePaginatedChannels.js.map +1 -1
- package/lib/module/components/Chat/Chat.js +10 -23
- package/lib/module/components/Chat/Chat.js.map +1 -1
- package/lib/module/components/Chat/hooks/useInitializeOfflineDb.js +64 -0
- package/lib/module/components/Chat/hooks/useInitializeOfflineDb.js.map +1 -0
- package/lib/module/index.js +7 -0
- package/lib/module/index.js.map +1 -1
- package/lib/module/mock-builders/DB/mock.js +13 -1
- package/lib/module/mock-builders/DB/mock.js.map +1 -1
- package/lib/module/store/OfflineDB.js +15 -2
- package/lib/module/store/OfflineDB.js.map +1 -1
- package/lib/module/store/SqliteClient.js +94 -10
- package/lib/module/store/SqliteClient.js.map +1 -1
- package/lib/module/test-utils/BetterSqlite.js +3 -2
- package/lib/module/test-utils/BetterSqlite.js.map +1 -1
- package/lib/module/version.json +1 -1
- package/lib/typescript/components/ChannelList/hooks/usePaginatedChannels.d.ts.map +1 -1
- package/lib/typescript/components/Chat/Chat.d.ts +44 -0
- package/lib/typescript/components/Chat/Chat.d.ts.map +1 -1
- package/lib/typescript/components/Chat/hooks/useInitializeOfflineDb.d.ts +27 -0
- package/lib/typescript/components/Chat/hooks/useInitializeOfflineDb.d.ts.map +1 -0
- package/lib/typescript/hooks/useTranslatedMessage.d.ts.map +1 -1
- package/lib/typescript/index.d.ts +1 -1
- package/lib/typescript/index.d.ts.map +1 -1
- package/lib/typescript/store/OfflineDB.d.ts +16 -1
- package/lib/typescript/store/OfflineDB.d.ts.map +1 -1
- package/lib/typescript/store/SqliteClient.d.ts +61 -0
- package/lib/typescript/store/SqliteClient.d.ts.map +1 -1
- package/lib/typescript/test-utils/BetterSqlite.d.ts.map +1 -1
- package/package.json +2 -2
- package/src/components/ChannelList/__tests__/ChannelList.test.js +87 -20
- package/src/components/ChannelList/hooks/usePaginatedChannels.ts +11 -6
- package/src/components/Chat/Chat.tsx +52 -18
- package/src/components/Chat/__tests__/ChatOfflineDbEncryption.test.tsx +248 -0
- package/src/components/Chat/hooks/useInitializeOfflineDb.ts +109 -0
- package/src/index.ts +1 -1
- package/src/mock-builders/DB/mock.ts +16 -1
- package/src/store/OfflineDB.ts +38 -3
- package/src/store/SqliteClient.ts +185 -1
- package/src/store/__tests__/SqliteClient.test.ts +258 -0
- package/src/test-utils/BetterSqlite.js +4 -1
- package/src/version.json +1 -1
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import { useCallback, useEffect, useState } from 'react';
|
|
2
|
+
|
|
3
|
+
import type { StreamChat } from 'stream-chat';
|
|
4
|
+
|
|
5
|
+
import { useStableCallback } from '../../../hooks/useStableCallback';
|
|
6
|
+
import { OfflineDB } from '../../../store/OfflineDB';
|
|
7
|
+
import { SqliteClient, SqliteClientError } from '../../../store/SqliteClient';
|
|
8
|
+
|
|
9
|
+
export type InitializeOfflineDbOptions = {
|
|
10
|
+
/**
|
|
11
|
+
* Encrypts the offline database at rest with SQLCipher, using the key this resolves
|
|
12
|
+
* to. Leaving it unset opens the database unencrypted, which is the default. See
|
|
13
|
+
* `ChatProps.getOfflineDbEncryptionKey` for the build flag it requires, the stability
|
|
14
|
+
* requirement, and how failures are surfaced.
|
|
15
|
+
*/
|
|
16
|
+
getEncryptionKey?: () => Promise<string | undefined>;
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
export type UseInitializeOfflineDbParams = {
|
|
20
|
+
client: StreamChat;
|
|
21
|
+
/** Whether offline support is enabled at all. */
|
|
22
|
+
enabled: boolean;
|
|
23
|
+
options?: InitializeOfflineDbOptions;
|
|
24
|
+
userID?: string;
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Attaches an offline database to the client and initializes it for a user.
|
|
29
|
+
*
|
|
30
|
+
* **Raises** whatever prevented the database from opening, from render, so an error
|
|
31
|
+
* boundary above the caller can decide what to do. The offline database is never
|
|
32
|
+
* silently downgraded, because an integration that asked for encryption must not end
|
|
33
|
+
* up with an unencrypted cache.
|
|
34
|
+
*/
|
|
35
|
+
export const useInitializeOfflineDb = ({
|
|
36
|
+
client,
|
|
37
|
+
enabled,
|
|
38
|
+
options,
|
|
39
|
+
userID,
|
|
40
|
+
}: UseInitializeOfflineDbParams) => {
|
|
41
|
+
/**
|
|
42
|
+
* Why this attempt could not open the offline database.
|
|
43
|
+
*
|
|
44
|
+
* Held per attempt rather than read from a longer-lived source: a value that outlived
|
|
45
|
+
* the attempt would be seen during the first render after a re-mount and raised
|
|
46
|
+
* before that mount's own attempt could run, so an error boundary that re-mounts to
|
|
47
|
+
* retry would loop forever.
|
|
48
|
+
*/
|
|
49
|
+
const [initializationError, setInitializationError] = useState<SqliteClientError>();
|
|
50
|
+
|
|
51
|
+
const { getEncryptionKey } = options ?? {};
|
|
52
|
+
|
|
53
|
+
// `getEncryptionKey` is overwhelmingly likely to be an inline arrow. Stabilising it
|
|
54
|
+
// keeps a new identity per render out of the dependencies below, while still calling
|
|
55
|
+
// whatever the latest prop is.
|
|
56
|
+
const resolveEncryptionKey = useStableCallback(
|
|
57
|
+
() => getEncryptionKey?.() ?? Promise.resolve(undefined),
|
|
58
|
+
);
|
|
59
|
+
const isEncryptionEnabled = !!getEncryptionKey;
|
|
60
|
+
|
|
61
|
+
const initialize = useCallback(async () => {
|
|
62
|
+
if (!(userID && enabled)) {
|
|
63
|
+
return;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
if (!client.offlineDb) {
|
|
67
|
+
const keyGetter = isEncryptionEnabled ? resolveEncryptionKey : undefined;
|
|
68
|
+
|
|
69
|
+
// Confirm the database can be opened before attaching it: the client writes
|
|
70
|
+
// through `client.offlineDb` without checking that it initialized, so one we
|
|
71
|
+
// cannot open turns those writes into rejections (and UI is affected directly).
|
|
72
|
+
if (keyGetter) {
|
|
73
|
+
SqliteClient.getEncryptionKey = keyGetter;
|
|
74
|
+
try {
|
|
75
|
+
await SqliteClient.preflightEncryption();
|
|
76
|
+
} catch (error) {
|
|
77
|
+
if (error instanceof SqliteClientError) {
|
|
78
|
+
setInitializationError(error);
|
|
79
|
+
return;
|
|
80
|
+
}
|
|
81
|
+
throw error;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
client.setOfflineDBApi(new OfflineDB({ client, getEncryptionKey: keyGetter }));
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const { offlineDb } = client;
|
|
89
|
+
if (offlineDb) {
|
|
90
|
+
await offlineDb.init(userID);
|
|
91
|
+
// Note: Since `init()` currently swallows errors by design, we have to rely
|
|
92
|
+
// on consuming the error later in order to be able to still rethrow without
|
|
93
|
+
// introducing a breaking change.
|
|
94
|
+
// TODO: The DB API should be changed in the next major to always throw upwards
|
|
95
|
+
// and let integrators handle it if necessary.
|
|
96
|
+
setInitializationError(
|
|
97
|
+
offlineDb instanceof OfflineDB ? offlineDb.initializationError : undefined,
|
|
98
|
+
);
|
|
99
|
+
}
|
|
100
|
+
}, [client, enabled, isEncryptionEnabled, resolveEncryptionKey, userID]);
|
|
101
|
+
|
|
102
|
+
useEffect(() => {
|
|
103
|
+
initialize();
|
|
104
|
+
}, [initialize]);
|
|
105
|
+
|
|
106
|
+
if (initializationError) {
|
|
107
|
+
throw initializationError;
|
|
108
|
+
}
|
|
109
|
+
};
|
package/src/index.ts
CHANGED
|
@@ -32,7 +32,7 @@ export { default as ruTranslations } from './i18n/ru.json';
|
|
|
32
32
|
export { default as trTranslations } from './i18n/tr.json';
|
|
33
33
|
|
|
34
34
|
export * from './state-store';
|
|
35
|
-
export { SqliteClient } from './store/SqliteClient';
|
|
35
|
+
export { SqliteClient, SqliteClientError, type SqliteClientErrorCode } from './store/SqliteClient';
|
|
36
36
|
export { OfflineDB } from './store/OfflineDB';
|
|
37
37
|
export { version } from './version.json';
|
|
38
38
|
|
|
@@ -1,12 +1,21 @@
|
|
|
1
|
+
import { rmSync } from 'fs';
|
|
2
|
+
|
|
1
3
|
import Sqlite3 from 'better-sqlite3';
|
|
2
4
|
|
|
3
5
|
import type { PreparedQueries } from '../../store/types';
|
|
4
6
|
|
|
5
7
|
let db: Sqlite3.Database;
|
|
8
|
+
// Per jest worker: workers run in parallel, and `delete()` below unlinks this file.
|
|
9
|
+
const testDbName = `foobar-${process.env.JEST_WORKER_ID ?? '0'}.db`;
|
|
6
10
|
|
|
7
11
|
export const sqliteMock = {
|
|
12
|
+
// better-sqlite3 has no SQLCipher, so an `encryptionKey` passed to open() is
|
|
13
|
+
// simply ignored. Reporting a SQLCipher build keeps the encrypted path
|
|
14
|
+
// exercisable in tests; whether the bytes on disk are actually encrypted can
|
|
15
|
+
// only be verified on a device. Spy on this to test the build-missing guard.
|
|
16
|
+
isSQLCipher: () => true,
|
|
8
17
|
open: () => {
|
|
9
|
-
db = new Sqlite3(
|
|
18
|
+
db = new Sqlite3(testDbName);
|
|
10
19
|
return {
|
|
11
20
|
close: () => {
|
|
12
21
|
db.close();
|
|
@@ -15,6 +24,12 @@ export const sqliteMock = {
|
|
|
15
24
|
status: 0,
|
|
16
25
|
};
|
|
17
26
|
},
|
|
27
|
+
// Mirrors op-sqlite's delete(): closes the handle and unlinks the file, so a
|
|
28
|
+
// subsequent open() starts from an empty database.
|
|
29
|
+
delete: () => {
|
|
30
|
+
db.close();
|
|
31
|
+
rmSync(testDbName, { force: true });
|
|
32
|
+
},
|
|
18
33
|
execute: async (queryInput: string, params: unknown[]) => {
|
|
19
34
|
const query = queryInput.trim().toLowerCase();
|
|
20
35
|
|
package/src/store/OfflineDB.ts
CHANGED
|
@@ -9,11 +9,25 @@ import type {
|
|
|
9
9
|
} from 'stream-chat';
|
|
10
10
|
|
|
11
11
|
import * as api from './apis';
|
|
12
|
-
import { SqliteClient } from './SqliteClient';
|
|
12
|
+
import { SqliteClient, SqliteClientError } from './SqliteClient';
|
|
13
13
|
|
|
14
14
|
export class OfflineDB extends AbstractOfflineDB {
|
|
15
|
-
constructor({
|
|
15
|
+
constructor({
|
|
16
|
+
client,
|
|
17
|
+
getEncryptionKey,
|
|
18
|
+
}: {
|
|
19
|
+
client: StreamChat;
|
|
20
|
+
/**
|
|
21
|
+
* Supplies the SQLCipher key the offline database is opened with. See
|
|
22
|
+
* {@link SqliteClient.getEncryptionKey} for the stability requirement.
|
|
23
|
+
*/
|
|
24
|
+
getEncryptionKey?: () => Promise<string | undefined>;
|
|
25
|
+
}) {
|
|
16
26
|
super({ client });
|
|
27
|
+
// Assigned unconditionally: SqliteClient holds this statically, so leaving a
|
|
28
|
+
// previous instance's getter in place would keep encrypting after the caller
|
|
29
|
+
// stopped asking for it.
|
|
30
|
+
SqliteClient.getEncryptionKey = getEncryptionKey;
|
|
17
31
|
}
|
|
18
32
|
|
|
19
33
|
upsertCidsForQuery = api.upsertCidsForQuery;
|
|
@@ -102,5 +116,26 @@ export class OfflineDB extends AbstractOfflineDB {
|
|
|
102
116
|
|
|
103
117
|
executeSqlBatch = SqliteClient.executeSqlBatch;
|
|
104
118
|
|
|
105
|
-
|
|
119
|
+
/**
|
|
120
|
+
* Why the most recent {@link initializeDB} failed, if it did.
|
|
121
|
+
*
|
|
122
|
+
* `AbstractOfflineDB.init` catches whatever `initializeDB` throws and does not
|
|
123
|
+
* re-throw it, so a caller has no way to see the reason. Recording it here on the
|
|
124
|
+
* way out gives the caller something to read back once `init` has settled. Kept on
|
|
125
|
+
* the instance rather than a static so two clients cannot overwrite each other.
|
|
126
|
+
*/
|
|
127
|
+
initializationError: SqliteClientError | undefined;
|
|
128
|
+
|
|
129
|
+
initializeDB = async () => {
|
|
130
|
+
this.initializationError = undefined;
|
|
131
|
+
try {
|
|
132
|
+
return await SqliteClient.initializeDatabase();
|
|
133
|
+
} catch (error) {
|
|
134
|
+
if (error instanceof SqliteClientError) {
|
|
135
|
+
this.initializationError = error;
|
|
136
|
+
}
|
|
137
|
+
// Re-thrown so `AbstractOfflineDB.init` still marks the database uninitialized.
|
|
138
|
+
throw error;
|
|
139
|
+
}
|
|
140
|
+
};
|
|
106
141
|
}
|
|
@@ -23,6 +23,29 @@ import { tables } from './schema';
|
|
|
23
23
|
import { createCreateTableQuery } from './sqlite-utils/createCreateTableQuery';
|
|
24
24
|
import type { PreparedBatchQueries, PreparedQueries, Scalar, Table } from './types';
|
|
25
25
|
|
|
26
|
+
/**
|
|
27
|
+
* Why the offline database could not be opened. The first two only arise when
|
|
28
|
+
* {@link SqliteClient.getEncryptionKey} is set; `OFFLINE_DB_UNREADABLE` can also mean
|
|
29
|
+
* plain corruption, or a database left behind from the other encryption mode.
|
|
30
|
+
*/
|
|
31
|
+
export type SqliteClientErrorCode =
|
|
32
|
+
| 'SQLCIPHER_BUILD_MISSING'
|
|
33
|
+
| 'ENCRYPTION_KEY_UNAVAILABLE'
|
|
34
|
+
| 'OFFLINE_DB_UNREADABLE';
|
|
35
|
+
|
|
36
|
+
export class SqliteClientError extends Error {
|
|
37
|
+
public readonly code: SqliteClientErrorCode;
|
|
38
|
+
|
|
39
|
+
constructor(code: SqliteClientErrorCode, message: string, options?: { cause?: unknown }) {
|
|
40
|
+
super(message);
|
|
41
|
+
this.name = 'SqliteClientError';
|
|
42
|
+
this.code = code;
|
|
43
|
+
// Assigned here rather than passed through `super(message, { cause })` because
|
|
44
|
+
// Hermes does not reliably honour the ErrorOptions overload.
|
|
45
|
+
this.cause = options?.cause;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
26
49
|
/**
|
|
27
50
|
* SqliteClient takes care of any direct interaction with sqlite.
|
|
28
51
|
* This way usage @op-engineering/op-sqlite package is scoped to a single class/file.
|
|
@@ -35,10 +58,126 @@ export class SqliteClient {
|
|
|
35
58
|
static logger: Logger | undefined;
|
|
36
59
|
static db: DB | undefined;
|
|
37
60
|
|
|
61
|
+
/**
|
|
62
|
+
* Supplies the SQLCipher key the offline database is opened with; `undefined`
|
|
63
|
+
* opens it unencrypted, which is the default. The key must be stable for the
|
|
64
|
+
* lifetime of the database file - there is no rekey path, so a database this key
|
|
65
|
+
* cannot read raises `OFFLINE_DB_UNREADABLE` on the first page read. The file is
|
|
66
|
+
* left untouched; recovery is `SqliteClient.deleteDatabase()` and a re-mount.
|
|
67
|
+
*/
|
|
68
|
+
static getEncryptionKey: (() => Promise<string | undefined>) | undefined;
|
|
69
|
+
|
|
70
|
+
/** Key resolved by {@link preflightEncryption}, consumed by the next {@link openDB}. */
|
|
71
|
+
private static preflightedKey: string | undefined;
|
|
72
|
+
|
|
73
|
+
/** Busy/disk/memory failures. Checked first: wiping over these destroys a good db. */
|
|
74
|
+
private static TRANSIENT_ERROR =
|
|
75
|
+
/database is locked|SQLITE_BUSY|SQLITE_LOCKED|disk i\/o|SQLITE_IOERR|unable to open|SQLITE_CANTOPEN|out of memory|readonly/i;
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* The bytes on disk cannot be read with the key we have: wrong/rotated key,
|
|
79
|
+
* plaintext-encrypted mismatch or corruption. SQLCipher has no decrypt specific
|
|
80
|
+
* code and overloads NOTADB (26), occasionally CORRUPT (11).
|
|
81
|
+
*/
|
|
82
|
+
private static UNREADABLE_ERROR =
|
|
83
|
+
/not a database|file is encrypted|malformed|disk image is malformed|SQLite (?:error )?code:?\s*(?:26|11)\b|NOTADB|SQLITE_CORRUPT/i;
|
|
84
|
+
|
|
38
85
|
static getDbVersion = () => SqliteClient.dbVersion;
|
|
39
86
|
// Force a specific db version. This is mainly useful for testsuit.
|
|
40
87
|
static setDbVersion = (version: number) => (SqliteClient.dbVersion = version);
|
|
41
88
|
|
|
89
|
+
/**
|
|
90
|
+
* Records and re-throws. Deliberately does not write to the console: the error is
|
|
91
|
+
* thrown, so logging it here would duplicate whatever the caller's error boundary
|
|
92
|
+
* reports - and in dev React already logs every boundary-caught error, which is what
|
|
93
|
+
* LogBox turns red.
|
|
94
|
+
*/
|
|
95
|
+
private static recordError = (e: SqliteClientError) => {
|
|
96
|
+
this.logger?.('error', e.message, { tag: e.code });
|
|
97
|
+
|
|
98
|
+
throw e;
|
|
99
|
+
};
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Resolves the encryption key without opening the database, so callers can decide
|
|
103
|
+
* whether to attach an `OfflineDB` at all. Parts of the client write through
|
|
104
|
+
* `client.offlineDb` without checking that it initialized (`queryChannels` upserts
|
|
105
|
+
* into it), so attaching one we cannot open turns those writes into rejections.
|
|
106
|
+
*
|
|
107
|
+
* Throws {@link SqliteClientError}. The key is handed to the next
|
|
108
|
+
* {@link openDB} rather than read from `getEncryptionKey` twice.
|
|
109
|
+
*/
|
|
110
|
+
static preflightEncryption = async () => {
|
|
111
|
+
try {
|
|
112
|
+
this.preflightedKey = await this.resolveEncryptionKey();
|
|
113
|
+
} catch (e) {
|
|
114
|
+
if (e instanceof SqliteClientError) {
|
|
115
|
+
this.recordError(e);
|
|
116
|
+
}
|
|
117
|
+
throw e;
|
|
118
|
+
}
|
|
119
|
+
};
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* The key to open with, or `undefined` when the database is meant to be
|
|
123
|
+
* unencrypted. Throws rather than silently falling back to an unencrypted
|
|
124
|
+
* database, which would hand an integration that asked for encryption a plaintext
|
|
125
|
+
* cache of its users' messages.
|
|
126
|
+
*/
|
|
127
|
+
private static resolveEncryptionKey = async () => {
|
|
128
|
+
const { getEncryptionKey } = this;
|
|
129
|
+
|
|
130
|
+
if (!getEncryptionKey) {
|
|
131
|
+
return undefined;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// A non-SQLCipher build accepts `encryptionKey` at the JSI boundary and then
|
|
135
|
+
// drops it - plaintext database, no error anywhere. `isSQLCipher` has existed
|
|
136
|
+
// since op-sqlite 9, well below the peer floor, so the typeof check is not really
|
|
137
|
+
// necessary but we'll keep it in case something changes in the future so that
|
|
138
|
+
// we at least have a clearer error.
|
|
139
|
+
if (sqlite === undefined) {
|
|
140
|
+
throw new SqliteClientError(
|
|
141
|
+
'SQLCIPHER_BUILD_MISSING',
|
|
142
|
+
'An offline database encryption key was provided but "@op-engineering/op-sqlite" ' +
|
|
143
|
+
'is not installed.',
|
|
144
|
+
);
|
|
145
|
+
}
|
|
146
|
+
if (typeof sqlite.isSQLCipher !== 'function' || !sqlite.isSQLCipher()) {
|
|
147
|
+
throw new SqliteClientError(
|
|
148
|
+
'SQLCIPHER_BUILD_MISSING',
|
|
149
|
+
'An offline database encryption key was provided but @op-engineering/op-sqlite was ' +
|
|
150
|
+
'not built with SQLCipher, so the key would be silently ignored and the offline ' +
|
|
151
|
+
'database written in plaintext. Add { "op-sqlite": { "sqlcipher": true } } to your ' +
|
|
152
|
+
"application's package.json and rebuild, or stop providing a key.",
|
|
153
|
+
);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
let encryptionKey: string | undefined;
|
|
157
|
+
|
|
158
|
+
try {
|
|
159
|
+
encryptionKey = await getEncryptionKey();
|
|
160
|
+
} catch (error) {
|
|
161
|
+
throw new SqliteClientError(
|
|
162
|
+
'ENCRYPTION_KEY_UNAVAILABLE',
|
|
163
|
+
'The offline database encryption key getter threw, so the database cannot be opened.',
|
|
164
|
+
{ cause: error },
|
|
165
|
+
);
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// Not being handed a key is not the same as being handed the wrong one, so a locked
|
|
169
|
+
// keychain must not cost us a database we can still read later.
|
|
170
|
+
if (!encryptionKey) {
|
|
171
|
+
throw new SqliteClientError(
|
|
172
|
+
'ENCRYPTION_KEY_UNAVAILABLE',
|
|
173
|
+
'The offline database encryption key getter resolved without a key, so the database ' +
|
|
174
|
+
'cannot be opened.',
|
|
175
|
+
);
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
return encryptionKey;
|
|
179
|
+
};
|
|
180
|
+
|
|
42
181
|
static openDB = async () => {
|
|
43
182
|
try {
|
|
44
183
|
if (sqlite === undefined) {
|
|
@@ -46,13 +185,24 @@ export class SqliteClient {
|
|
|
46
185
|
'Please install "@op-engineering/op-sqlite" package to enable offline support',
|
|
47
186
|
);
|
|
48
187
|
}
|
|
188
|
+
const encryptionKey = this.preflightedKey ?? (await this.resolveEncryptionKey());
|
|
189
|
+
this.preflightedKey = undefined;
|
|
190
|
+
|
|
49
191
|
this.db = sqlite.open({
|
|
50
192
|
location: SqliteClient.dbLocation,
|
|
51
193
|
name: SqliteClient.dbName,
|
|
194
|
+
...(encryptionKey ? { encryptionKey } : {}),
|
|
52
195
|
});
|
|
53
196
|
|
|
197
|
+
// Note: this will not fail on an encryption key mismatch, as we do not read
|
|
198
|
+
// any pages, but rather look at a connection level flag. The first failure
|
|
199
|
+
// is going to be whatever actually reads something, which is going to be
|
|
200
|
+
// the user_version read in initializeDatabase.
|
|
54
201
|
await this.db?.execute('PRAGMA foreign_keys = ON', []);
|
|
55
202
|
} catch (e) {
|
|
203
|
+
if (e instanceof SqliteClientError) {
|
|
204
|
+
throw e;
|
|
205
|
+
}
|
|
56
206
|
this.logger?.('error', `Error opening database ${SqliteClient.dbName}`, {
|
|
57
207
|
error: e,
|
|
58
208
|
});
|
|
@@ -154,7 +304,23 @@ export class SqliteClient {
|
|
|
154
304
|
return true;
|
|
155
305
|
};
|
|
156
306
|
|
|
157
|
-
|
|
307
|
+
/**
|
|
308
|
+
* Whether the file cannot be read with the key we have, as opposed to being
|
|
309
|
+
* temporarily unavailable (busy, locked, disk). Works off message text because
|
|
310
|
+
* op-sqlite rejects with a plain Error and this class re-wraps those messages, so
|
|
311
|
+
* no numeric code survives. Drives `OFFLINE_DB_UNREADABLE`.
|
|
312
|
+
*/
|
|
313
|
+
static isUnreadableDbError = (e: unknown) => {
|
|
314
|
+
const message = String((e as Error)?.message ?? e);
|
|
315
|
+
|
|
316
|
+
if (this.TRANSIENT_ERROR.test(message)) {
|
|
317
|
+
return false;
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
return this.UNREADABLE_ERROR.test(message);
|
|
321
|
+
};
|
|
322
|
+
|
|
323
|
+
static initializeDatabase = async (): Promise<boolean> => {
|
|
158
324
|
try {
|
|
159
325
|
await SqliteClient.openDB();
|
|
160
326
|
const version = await SqliteClient.getUserPragmaVersion();
|
|
@@ -180,6 +346,24 @@ export class SqliteClient {
|
|
|
180
346
|
|
|
181
347
|
return true;
|
|
182
348
|
} catch (e) {
|
|
349
|
+
if (e instanceof SqliteClientError) {
|
|
350
|
+
this.recordError(e);
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
if (this.isUnreadableDbError(e)) {
|
|
354
|
+
this.recordError(
|
|
355
|
+
new SqliteClientError(
|
|
356
|
+
'OFFLINE_DB_UNREADABLE',
|
|
357
|
+
'The offline database exists but could not be read. Usually the encryption ' +
|
|
358
|
+
'key changed, or encryption was turned on or off while a database from ' +
|
|
359
|
+
'the other mode was still on disk. Delete it with ' +
|
|
360
|
+
'SqliteClient.deleteDatabase() and re-mount to rebuild from the server - ' +
|
|
361
|
+
'everything in it is a cache, except queued offline actions, which are lost.',
|
|
362
|
+
{ cause: e },
|
|
363
|
+
),
|
|
364
|
+
);
|
|
365
|
+
}
|
|
366
|
+
|
|
183
367
|
console.log('Error initializing DB', e);
|
|
184
368
|
this.logger?.('error', 'Error initializing DB', {
|
|
185
369
|
dbLocation: SqliteClient.dbLocation,
|