stream-chat-react-native-core 8.13.19 → 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.
Files changed (52) hide show
  1. package/lib/commonjs/components/Chat/Chat.js +10 -23
  2. package/lib/commonjs/components/Chat/Chat.js.map +1 -1
  3. package/lib/commonjs/components/Chat/hooks/useInitializeOfflineDb.js +64 -0
  4. package/lib/commonjs/components/Chat/hooks/useInitializeOfflineDb.js.map +1 -0
  5. package/lib/commonjs/index.js +7 -0
  6. package/lib/commonjs/index.js.map +1 -1
  7. package/lib/commonjs/mock-builders/DB/mock.js +13 -1
  8. package/lib/commonjs/mock-builders/DB/mock.js.map +1 -1
  9. package/lib/commonjs/store/OfflineDB.js +15 -2
  10. package/lib/commonjs/store/OfflineDB.js.map +1 -1
  11. package/lib/commonjs/store/SqliteClient.js +94 -10
  12. package/lib/commonjs/store/SqliteClient.js.map +1 -1
  13. package/lib/commonjs/test-utils/BetterSqlite.js +3 -2
  14. package/lib/commonjs/test-utils/BetterSqlite.js.map +1 -1
  15. package/lib/commonjs/version.json +1 -1
  16. package/lib/module/components/Chat/Chat.js +10 -23
  17. package/lib/module/components/Chat/Chat.js.map +1 -1
  18. package/lib/module/components/Chat/hooks/useInitializeOfflineDb.js +64 -0
  19. package/lib/module/components/Chat/hooks/useInitializeOfflineDb.js.map +1 -0
  20. package/lib/module/index.js +7 -0
  21. package/lib/module/index.js.map +1 -1
  22. package/lib/module/mock-builders/DB/mock.js +13 -1
  23. package/lib/module/mock-builders/DB/mock.js.map +1 -1
  24. package/lib/module/store/OfflineDB.js +15 -2
  25. package/lib/module/store/OfflineDB.js.map +1 -1
  26. package/lib/module/store/SqliteClient.js +94 -10
  27. package/lib/module/store/SqliteClient.js.map +1 -1
  28. package/lib/module/test-utils/BetterSqlite.js +3 -2
  29. package/lib/module/test-utils/BetterSqlite.js.map +1 -1
  30. package/lib/module/version.json +1 -1
  31. package/lib/typescript/components/Chat/Chat.d.ts +44 -0
  32. package/lib/typescript/components/Chat/Chat.d.ts.map +1 -1
  33. package/lib/typescript/components/Chat/hooks/useInitializeOfflineDb.d.ts +27 -0
  34. package/lib/typescript/components/Chat/hooks/useInitializeOfflineDb.d.ts.map +1 -0
  35. package/lib/typescript/index.d.ts +1 -1
  36. package/lib/typescript/index.d.ts.map +1 -1
  37. package/lib/typescript/store/OfflineDB.d.ts +16 -1
  38. package/lib/typescript/store/OfflineDB.d.ts.map +1 -1
  39. package/lib/typescript/store/SqliteClient.d.ts +61 -0
  40. package/lib/typescript/store/SqliteClient.d.ts.map +1 -1
  41. package/lib/typescript/test-utils/BetterSqlite.d.ts.map +1 -1
  42. package/package.json +1 -1
  43. package/src/components/Chat/Chat.tsx +52 -18
  44. package/src/components/Chat/__tests__/ChatOfflineDbEncryption.test.tsx +248 -0
  45. package/src/components/Chat/hooks/useInitializeOfflineDb.ts +109 -0
  46. package/src/index.ts +1 -1
  47. package/src/mock-builders/DB/mock.ts +16 -1
  48. package/src/store/OfflineDB.ts +38 -3
  49. package/src/store/SqliteClient.ts +185 -1
  50. package/src/store/__tests__/SqliteClient.test.ts +258 -0
  51. package/src/test-utils/BetterSqlite.js +4 -1
  52. package/src/version.json +1 -1
@@ -5,6 +5,7 @@ import { Channel, OfflineDBState } from 'stream-chat';
5
5
 
6
6
  import { useAppSettings } from './hooks/useAppSettings';
7
7
  import { useCreateChatContext } from './hooks/useCreateChatContext';
8
+ import { useInitializeOfflineDb } from './hooks/useInitializeOfflineDb';
8
9
  import { useIsOnline } from './hooks/useIsOnline';
9
10
  import { useMutedUsers } from './hooks/useMutedUsers';
10
11
 
@@ -22,7 +23,6 @@ import { useStreami18n } from '../../hooks/useStreami18n';
22
23
  import init from '../../init';
23
24
 
24
25
  import { NativeHandlers } from '../../native';
25
- import { OfflineDB } from '../../store/OfflineDB';
26
26
 
27
27
  import type { Streami18n } from '../../utils/i18n/Streami18n';
28
28
  import { version } from '../../version.json';
@@ -42,6 +42,50 @@ export type ChatProps = Pick<ChatContextValue, 'client'> &
42
42
  * Enables offline storage and loading for chat data.
43
43
  */
44
44
  enableOfflineSupport?: boolean;
45
+ /**
46
+ * Encrypts the offline database at rest with SQLCipher, using the key this
47
+ * resolves to. Only relevant when `enableOfflineSupport` is enabled. Leaving it
48
+ * unset keeps the offline database unencrypted, which is the default.
49
+ *
50
+ * Requires a native build of `@op-engineering/op-sqlite` that includes SQLCipher.
51
+ * Add the following to your application's `package.json` and rebuild the native
52
+ * app - without the flag the key is accepted and then silently ignored:
53
+ *
54
+ * ```json
55
+ * { "op-sqlite": { "sqlcipher": true } }
56
+ * ```
57
+ *
58
+ * **Wrap `<Chat>` in an error boundary.** If the database cannot be opened with
59
+ * the encryption you asked for, `<Chat>` throws a {@link SqliteClientError}
60
+ * from render instead of continuing without it. The SDK deliberately takes no
61
+ * recovery action of its own - it never deletes data, and never silently falls
62
+ * back to an unencrypted or absent cache. Discriminate on `code`:
63
+ *
64
+ * - `OFFLINE_DB_UNREADABLE` - the file exists but this key cannot read it (the
65
+ * key changed, or the database predates encryption). **Recommended recovery:
66
+ * `SqliteClient.deleteDatabase()`, then re-mount `<Chat>`.** The contents are a
67
+ * cache and are refetched from the server; the exception is actions queued while
68
+ * offline, which are lost - prompt the user first if that matters to you.
69
+ * - `ENCRYPTION_KEY_UNAVAILABLE` - the key could not be read (a locked keychain, a
70
+ * launch before first unlock). The database is untouched. **Recommended
71
+ * recovery: re-mount to retry** once the key is readable - for example when the
72
+ * app next returns to the foreground.
73
+ * - `SQLCIPHER_BUILD_MISSING` - the native build has no SQLCipher, so the key
74
+ * would be ignored and the database written in plaintext. Not recoverable at
75
+ * runtime; it needs the build flag above and a new binary. **Recommended
76
+ * recovery: re-mount with `enableOfflineSupport={false}`** so nothing is
77
+ * persisted unencrypted.
78
+ *
79
+ * The key must be **stable for the lifetime of the database file**. There is no
80
+ * rekey path, so a key that changes costs one `OFFLINE_DB_UNREADABLE` and a
81
+ * rebuild. To rotate without paying that, rotate a key-encryption key and keep the
82
+ * database key it protects unchanged (envelope encryption).
83
+ *
84
+ * Switching encryption on, or back off, leaves a database from the other mode on
85
+ * disk and so raises `OFFLINE_DB_UNREADABLE` once in each direction. Deleting it
86
+ * from your boundary is all that is needed.
87
+ */
88
+ getOfflineDbEncryptionKey?: () => Promise<string | undefined>;
45
89
  /**
46
90
  * Instance of Streami18n class should be provided to Chat component to enable internationalization.
47
91
  *
@@ -143,6 +187,7 @@ const ChatWithContext = (props: PropsWithChildren<ChatProps>) => {
143
187
  client,
144
188
  closeConnectionOnBackground = true,
145
189
  enableOfflineSupport = false,
190
+ getOfflineDbEncryptionKey,
146
191
  i18nInstance,
147
192
  ImageComponent = Image,
148
193
  isMessageAIGenerated,
@@ -211,23 +256,12 @@ const ChatWithContext = (props: PropsWithChildren<ChatProps>) => {
211
256
 
212
257
  const setActiveChannel = (newChannel?: Channel) => setChannel(newChannel);
213
258
 
214
- useEffect(() => {
215
- if (!(userID && enableOfflineSupport)) {
216
- return;
217
- }
218
-
219
- const initializeDatabase = async () => {
220
- if (!client.offlineDb) {
221
- client.setOfflineDBApi(new OfflineDB({ client }));
222
- }
223
-
224
- if (client.offlineDb) {
225
- await client.offlineDb.init(userID);
226
- }
227
- };
228
-
229
- initializeDatabase();
230
- }, [userID, enableOfflineSupport, client]);
259
+ useInitializeOfflineDb({
260
+ client,
261
+ enabled: enableOfflineSupport,
262
+ options: { getEncryptionKey: getOfflineDbEncryptionKey },
263
+ userID,
264
+ });
231
265
 
232
266
  useEffect(() => {
233
267
  if (!client) {
@@ -0,0 +1,248 @@
1
+ import React, { PropsWithChildren } from 'react';
2
+ import { View } from 'react-native';
3
+
4
+ import { cleanup, render, waitFor } from '@testing-library/react-native';
5
+
6
+ import { sqliteMock } from '../../../mock-builders/DB/mock';
7
+ import { getTestClientWithUser } from '../../../mock-builders/mock';
8
+ import { SqliteClient, SqliteClientError } from '../../../store/SqliteClient';
9
+ import { Chat } from '../Chat';
10
+
11
+ describe('Chat offline DB encryption', () => {
12
+ const installedSpies: jest.SpyInstance[] = [];
13
+
14
+ /**
15
+ * Registers a spy for teardown. Deliberately not jest.restoreAllMocks(): that also
16
+ * restores the connection privates mockClient() stubs out on every client created
17
+ * by earlier tests in this file, after which those clients reconnect for real and
18
+ * the failed websocket handshake resurfaces as an unhandled error somewhere else.
19
+ */
20
+ const track = <T extends jest.SpyInstance>(spy: T): T => {
21
+ installedSpies.push(spy);
22
+ return spy;
23
+ };
24
+
25
+ /**
26
+ * Chat mounts useIsOnline, which opens the websocket whenever the app comes to the
27
+ * foreground. Left real, that connection attempt outlives the test and rejects
28
+ * asynchronously. Nothing in this block needs a connection.
29
+ */
30
+ const createClient = async () => {
31
+ const client = await getTestClientWithUser({ id: 'testID' });
32
+ track(jest.spyOn(client, 'openConnection').mockResolvedValue(undefined));
33
+ track(jest.spyOn(client, 'closeConnection').mockResolvedValue(undefined));
34
+ return client;
35
+ };
36
+
37
+ /** Minimal error boundary, since `<Chat>` reports encryption failures by throwing. */
38
+ class Boundary extends React.Component<
39
+ PropsWithChildren<{ onCatch: (error: Error) => void }>,
40
+ { caught: boolean }
41
+ > {
42
+ state = { caught: false };
43
+
44
+ static getDerivedStateFromError() {
45
+ return { caught: true };
46
+ }
47
+
48
+ componentDidCatch(error: Error) {
49
+ this.props.onCatch(error);
50
+ }
51
+
52
+ render() {
53
+ return this.state.caught ? <View testID='boundary' /> : this.props.children;
54
+ }
55
+ }
56
+
57
+ afterEach(() => {
58
+ cleanup();
59
+ installedSpies.splice(0).forEach((spy) => spy.mockRestore());
60
+ SqliteClient.getEncryptionKey = undefined;
61
+ });
62
+
63
+ it('does not configure an encryption key when the prop is omitted', async () => {
64
+ const chatClientWithUser = await createClient();
65
+
66
+ render(<Chat client={chatClientWithUser} enableOfflineSupport />);
67
+
68
+ await waitFor(() => expect(chatClientWithUser.offlineDb).toBeDefined());
69
+ expect(SqliteClient.getEncryptionKey).toBeUndefined();
70
+ });
71
+
72
+ it('forwards getOfflineDbEncryptionKey to the sqlite client', async () => {
73
+ const chatClientWithUser = await createClient();
74
+ const getOfflineDbEncryptionKey = jest.fn().mockResolvedValue('a-stable-key');
75
+
76
+ render(
77
+ <Chat
78
+ client={chatClientWithUser}
79
+ enableOfflineSupport
80
+ getOfflineDbEncryptionKey={getOfflineDbEncryptionKey}
81
+ />,
82
+ );
83
+
84
+ await waitFor(() => expect(chatClientWithUser.offlineDb).toBeDefined());
85
+ await waitFor(() => expect(getOfflineDbEncryptionKey).toHaveBeenCalled());
86
+ });
87
+
88
+ it('does not re-initialize when getOfflineDbEncryptionKey is a new function every render', async () => {
89
+ const chatClientWithUser = await createClient();
90
+ const resolveKey = jest.fn().mockResolvedValue('a-stable-key');
91
+
92
+ // An inline arrow is the shape integrators reach for first, so a changing
93
+ // identity must not restart initialization on every render.
94
+ const { rerender } = render(
95
+ <Chat
96
+ client={chatClientWithUser}
97
+ enableOfflineSupport
98
+ getOfflineDbEncryptionKey={() => resolveKey()}
99
+ />,
100
+ );
101
+
102
+ await waitFor(() => expect(chatClientWithUser.offlineDb).toBeDefined());
103
+ const initSpy = track(jest.spyOn(chatClientWithUser.offlineDb!, 'init'));
104
+
105
+ rerender(
106
+ <Chat
107
+ client={chatClientWithUser}
108
+ enableOfflineSupport
109
+ getOfflineDbEncryptionKey={() => resolveKey()}
110
+ />,
111
+ );
112
+ rerender(
113
+ <Chat
114
+ client={chatClientWithUser}
115
+ enableOfflineSupport
116
+ getOfflineDbEncryptionKey={() => resolveKey()}
117
+ />,
118
+ );
119
+
120
+ await waitFor(() => expect(initSpy).not.toHaveBeenCalled());
121
+ });
122
+
123
+ it.each<[string, () => Promise<string | undefined>, string]>([
124
+ ['the key cannot be read', () => Promise.resolve(undefined), 'ENCRYPTION_KEY_UNAVAILABLE'],
125
+ [
126
+ 'the key getter throws',
127
+ () => Promise.reject(new Error('keychain is locked')),
128
+ 'ENCRYPTION_KEY_UNAVAILABLE',
129
+ ],
130
+ ])('throws %s so an error boundary can decide', async (_label, getKey, code) => {
131
+ const chatClientWithUser = await createClient();
132
+ track(jest.spyOn(console, 'warn').mockImplementation(() => undefined));
133
+ track(jest.spyOn(console, 'error').mockImplementation(() => undefined));
134
+ track(jest.spyOn(console, 'log').mockImplementation(() => undefined));
135
+ const onCatch = jest.fn();
136
+
137
+ const { getByTestId } = render(
138
+ <Boundary onCatch={onCatch}>
139
+ <Chat client={chatClientWithUser} enableOfflineSupport getOfflineDbEncryptionKey={getKey}>
140
+ <View testID='children' />
141
+ </Chat>
142
+ </Boundary>,
143
+ );
144
+
145
+ await waitFor(() => expect(getByTestId('boundary')).toBeTruthy());
146
+ expect(onCatch).toHaveBeenCalledWith(expect.any(SqliteClientError));
147
+ expect((onCatch.mock.calls[0][0] as SqliteClientError).code).toBe(code);
148
+ // Never silently downgraded to online-only.
149
+ expect(() => getByTestId('children')).toThrow();
150
+ });
151
+
152
+ it('throws when the native build has no SQLCipher', async () => {
153
+ const chatClientWithUser = await createClient();
154
+ track(jest.spyOn(console, 'error').mockImplementation(() => undefined));
155
+ track(jest.spyOn(console, 'log').mockImplementation(() => undefined));
156
+ track(jest.spyOn(sqliteMock, 'isSQLCipher').mockReturnValue(false));
157
+ const onCatch = jest.fn();
158
+
159
+ const { getByTestId } = render(
160
+ <Boundary onCatch={onCatch}>
161
+ <Chat
162
+ client={chatClientWithUser}
163
+ enableOfflineSupport
164
+ getOfflineDbEncryptionKey={() => Promise.resolve('a-stable-key')}
165
+ />
166
+ </Boundary>,
167
+ );
168
+
169
+ await waitFor(() => expect(getByTestId('boundary')).toBeTruthy());
170
+ expect((onCatch.mock.calls[0][0] as SqliteClientError).code).toBe('SQLCIPHER_BUILD_MISSING');
171
+ });
172
+
173
+ it('throws OFFLINE_DB_UNREADABLE without deleting the database', async () => {
174
+ const chatClientWithUser = await createClient();
175
+ track(jest.spyOn(console, 'warn').mockImplementation(() => undefined));
176
+ track(jest.spyOn(console, 'error').mockImplementation(() => undefined));
177
+ track(jest.spyOn(console, 'log').mockImplementation(() => undefined));
178
+ // Preflight passes, then the first read of the file fails to decrypt.
179
+ track(
180
+ jest
181
+ .spyOn(SqliteClient, 'getUserPragmaVersion')
182
+ .mockRejectedValue(new Error('Querying for user_version failed: file is not a database')),
183
+ );
184
+ const deleteSpy = track(jest.spyOn(SqliteClient, 'deleteDatabase'));
185
+ const onCatch = jest.fn();
186
+
187
+ const { getByTestId } = render(
188
+ <Boundary onCatch={onCatch}>
189
+ <Chat
190
+ client={chatClientWithUser}
191
+ enableOfflineSupport
192
+ getOfflineDbEncryptionKey={() => Promise.resolve('a-stable-key')}
193
+ />
194
+ </Boundary>,
195
+ );
196
+
197
+ await waitFor(() => expect(getByTestId('boundary')).toBeTruthy());
198
+ expect((onCatch.mock.calls[0][0] as SqliteClientError).code).toBe('OFFLINE_DB_UNREADABLE');
199
+ // Wiping is the integrator's decision, made from the boundary.
200
+ expect(deleteSpy).not.toHaveBeenCalled();
201
+ });
202
+
203
+ it('never attaches an offline DB it cannot open', async () => {
204
+ const chatClientWithUser = await createClient();
205
+ track(jest.spyOn(console, 'warn').mockImplementation(() => undefined));
206
+ track(jest.spyOn(console, 'error').mockImplementation(() => undefined));
207
+ track(jest.spyOn(console, 'log').mockImplementation(() => undefined));
208
+ const setOfflineDBApiSpy = track(jest.spyOn(chatClientWithUser, 'setOfflineDBApi'));
209
+
210
+ const { getByTestId } = render(
211
+ <Boundary onCatch={() => undefined}>
212
+ <Chat
213
+ client={chatClientWithUser}
214
+ enableOfflineSupport
215
+ getOfflineDbEncryptionKey={() => Promise.resolve(undefined)}
216
+ />
217
+ </Boundary>,
218
+ );
219
+
220
+ await waitFor(() => expect(getByTestId('boundary')).toBeTruthy());
221
+
222
+ // Parts of the client write through `client.offlineDb` without checking that it
223
+ // initialized - queryChannels upserts into it - so an instance we cannot open
224
+ // would turn those writes into rejections.
225
+ expect(setOfflineDBApiSpy).not.toHaveBeenCalled();
226
+ expect(chatClientWithUser.offlineDb).toBeUndefined();
227
+ });
228
+
229
+ it('renders normally when nothing is wrong with encryption', async () => {
230
+ const chatClientWithUser = await createClient();
231
+ const onCatch = jest.fn();
232
+
233
+ const { getByTestId } = render(
234
+ <Boundary onCatch={onCatch}>
235
+ <Chat
236
+ client={chatClientWithUser}
237
+ enableOfflineSupport
238
+ getOfflineDbEncryptionKey={() => Promise.resolve('a-stable-key')}
239
+ >
240
+ <View testID='children' />
241
+ </Chat>
242
+ </Boundary>,
243
+ );
244
+
245
+ await waitFor(() => expect(getByTestId('children')).toBeTruthy());
246
+ expect(onCatch).not.toHaveBeenCalled();
247
+ });
248
+ });
@@ -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('foobar.db');
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
 
@@ -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({ client }: { client: StreamChat }) {
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
- initializeDB = SqliteClient.initializeDatabase;
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
  }