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,258 @@
|
|
|
1
|
+
import { sqliteMock } from '../../mock-builders/DB/mock';
|
|
2
|
+
import { SqliteClient, SqliteClientError } from '../SqliteClient';
|
|
3
|
+
|
|
4
|
+
// Captured before any spy is installed so the spy can call through to a real
|
|
5
|
+
// better-sqlite3 handle while still observing the arguments open() was given.
|
|
6
|
+
const openDatabase = sqliteMock.open;
|
|
7
|
+
|
|
8
|
+
/** Runs `initializeDatabase` once and returns the error it threw. */
|
|
9
|
+
const captureInitError = async () => {
|
|
10
|
+
try {
|
|
11
|
+
await SqliteClient.initializeDatabase();
|
|
12
|
+
} catch (error) {
|
|
13
|
+
return error as SqliteClientError;
|
|
14
|
+
}
|
|
15
|
+
throw new Error('expected initializeDatabase to reject, but it resolved');
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
describe('SqliteClient encryption', () => {
|
|
19
|
+
let openSpy: jest.SpyInstance<ReturnType<typeof sqliteMock.open>>;
|
|
20
|
+
let deleteMocks: jest.Mock[];
|
|
21
|
+
|
|
22
|
+
beforeEach(() => {
|
|
23
|
+
SqliteClient.getEncryptionKey = undefined;
|
|
24
|
+
SqliteClient.db = undefined;
|
|
25
|
+
SqliteClient.logger = jest.fn();
|
|
26
|
+
|
|
27
|
+
deleteMocks = [];
|
|
28
|
+
openSpy = jest.spyOn(sqliteMock, 'open').mockImplementation(() => {
|
|
29
|
+
const db = openDatabase();
|
|
30
|
+
const originalDelete = db.delete;
|
|
31
|
+
const deleteMock = jest.fn(() => originalDelete());
|
|
32
|
+
deleteMocks.push(deleteMock);
|
|
33
|
+
return { ...db, delete: deleteMock };
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
jest.spyOn(console, 'warn').mockImplementation(() => undefined);
|
|
37
|
+
jest.spyOn(console, 'error').mockImplementation(() => undefined);
|
|
38
|
+
jest.spyOn(console, 'log').mockImplementation(() => undefined);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
afterEach(() => {
|
|
42
|
+
jest.restoreAllMocks();
|
|
43
|
+
SqliteClient.getEncryptionKey = undefined;
|
|
44
|
+
SqliteClient.logger = undefined;
|
|
45
|
+
SqliteClient.db = undefined;
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
describe('opening without encryption', () => {
|
|
49
|
+
it('does not pass an encryption key when no getter is configured', async () => {
|
|
50
|
+
await expect(SqliteClient.initializeDatabase()).resolves.toBe(true);
|
|
51
|
+
|
|
52
|
+
expect(openSpy).toHaveBeenCalledTimes(1);
|
|
53
|
+
expect(openSpy.mock.calls[0][0]).not.toHaveProperty('encryptionKey');
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it('never consults isSQLCipher when no getter is configured', async () => {
|
|
57
|
+
const isSQLCipherSpy = jest.spyOn(sqliteMock, 'isSQLCipher');
|
|
58
|
+
|
|
59
|
+
await SqliteClient.initializeDatabase();
|
|
60
|
+
|
|
61
|
+
expect(isSQLCipherSpy).not.toHaveBeenCalled();
|
|
62
|
+
});
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
describe('opening with encryption', () => {
|
|
66
|
+
it('passes the resolved key to open()', async () => {
|
|
67
|
+
SqliteClient.getEncryptionKey = jest.fn().mockResolvedValue('a-stable-key');
|
|
68
|
+
|
|
69
|
+
await expect(SqliteClient.initializeDatabase()).resolves.toBe(true);
|
|
70
|
+
|
|
71
|
+
expect(SqliteClient.getEncryptionKey).toHaveBeenCalledTimes(1);
|
|
72
|
+
expect(openSpy.mock.calls[0][0]).toMatchObject({ encryptionKey: 'a-stable-key' });
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it('refuses to open at all when op-sqlite has no SQLCipher build', async () => {
|
|
76
|
+
jest.spyOn(sqliteMock, 'isSQLCipher').mockReturnValue(false);
|
|
77
|
+
SqliteClient.getEncryptionKey = jest.fn().mockResolvedValue('a-stable-key');
|
|
78
|
+
|
|
79
|
+
const error = await captureInitError();
|
|
80
|
+
|
|
81
|
+
expect(error).toBeInstanceOf(SqliteClientError);
|
|
82
|
+
|
|
83
|
+
expect(error.code).toBe('SQLCIPHER_BUILD_MISSING');
|
|
84
|
+
// The whole point: no database is created, so nothing is written in plaintext.
|
|
85
|
+
expect(openSpy).not.toHaveBeenCalled();
|
|
86
|
+
// Nor is the key ever requested - the build is unusable regardless of it.
|
|
87
|
+
expect(SqliteClient.getEncryptionKey).not.toHaveBeenCalled();
|
|
88
|
+
expect(deleteMocks).toHaveLength(0);
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it('refuses to open when isSQLCipher is missing from the installed op-sqlite', async () => {
|
|
92
|
+
// An op-sqlite too old to expose the check cannot be verified, so it is
|
|
93
|
+
// treated exactly like a build without SQLCipher.
|
|
94
|
+
const { isSQLCipher } = sqliteMock;
|
|
95
|
+
// @ts-expect-error deliberately simulating an older op-sqlite
|
|
96
|
+
delete sqliteMock.isSQLCipher;
|
|
97
|
+
SqliteClient.getEncryptionKey = jest.fn().mockResolvedValue('a-stable-key');
|
|
98
|
+
|
|
99
|
+
try {
|
|
100
|
+
const error = await captureInitError();
|
|
101
|
+
|
|
102
|
+
expect(error).toBeInstanceOf(SqliteClientError);
|
|
103
|
+
expect(error.code).toBe('SQLCIPHER_BUILD_MISSING');
|
|
104
|
+
expect(openSpy).not.toHaveBeenCalled();
|
|
105
|
+
} finally {
|
|
106
|
+
sqliteMock.isSQLCipher = isSQLCipher;
|
|
107
|
+
}
|
|
108
|
+
});
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
describe('when the encryption key cannot be obtained', () => {
|
|
112
|
+
it('gives up without wiping when the getter throws', async () => {
|
|
113
|
+
const cause = new Error('keychain is locked');
|
|
114
|
+
SqliteClient.getEncryptionKey = jest.fn().mockRejectedValue(cause);
|
|
115
|
+
|
|
116
|
+
const error = await captureInitError();
|
|
117
|
+
|
|
118
|
+
expect(error).toBeInstanceOf(SqliteClientError);
|
|
119
|
+
|
|
120
|
+
expect(error.code).toBe('ENCRYPTION_KEY_UNAVAILABLE');
|
|
121
|
+
expect(error.cause).toBe(cause);
|
|
122
|
+
expect(openSpy).not.toHaveBeenCalled();
|
|
123
|
+
// Not being handed a key says nothing about the database on disk, so it stays.
|
|
124
|
+
expect(deleteMocks).toHaveLength(0);
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
it('gives up without wiping when the getter resolves without a key', async () => {
|
|
128
|
+
SqliteClient.getEncryptionKey = jest.fn().mockResolvedValue(undefined);
|
|
129
|
+
|
|
130
|
+
const error = await captureInitError();
|
|
131
|
+
|
|
132
|
+
expect(error).toBeInstanceOf(SqliteClientError);
|
|
133
|
+
|
|
134
|
+
expect(error.code).toBe('ENCRYPTION_KEY_UNAVAILABLE');
|
|
135
|
+
expect(openSpy).not.toHaveBeenCalled();
|
|
136
|
+
expect(deleteMocks).toHaveLength(0);
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
it('treats an empty string as no key', async () => {
|
|
140
|
+
SqliteClient.getEncryptionKey = jest.fn().mockResolvedValue('');
|
|
141
|
+
|
|
142
|
+
const error = await captureInitError();
|
|
143
|
+
|
|
144
|
+
expect(error).toBeInstanceOf(SqliteClientError);
|
|
145
|
+
|
|
146
|
+
expect(error.code).toBe('ENCRYPTION_KEY_UNAVAILABLE');
|
|
147
|
+
expect(openSpy).not.toHaveBeenCalled();
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
it('clears a recorded encryption error once initialization succeeds', async () => {
|
|
151
|
+
SqliteClient.getEncryptionKey = jest.fn().mockResolvedValue(undefined);
|
|
152
|
+
const error = await captureInitError();
|
|
153
|
+
|
|
154
|
+
expect(error).toBeInstanceOf(SqliteClientError);
|
|
155
|
+
expect(error).toBeDefined();
|
|
156
|
+
|
|
157
|
+
SqliteClient.getEncryptionKey = jest.fn().mockResolvedValue('a-stable-key');
|
|
158
|
+
await expect(SqliteClient.initializeDatabase()).resolves.toBe(true);
|
|
159
|
+
});
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
describe('when the database cannot be read', () => {
|
|
163
|
+
const notADatabase = () =>
|
|
164
|
+
new Error('Querying for user_version failed: Error: file is not a database');
|
|
165
|
+
|
|
166
|
+
it('throws OFFLINE_DB_UNREADABLE instead of wiping it', async () => {
|
|
167
|
+
SqliteClient.getEncryptionKey = jest.fn().mockResolvedValue('a-stable-key');
|
|
168
|
+
jest.spyOn(SqliteClient, 'getUserPragmaVersion').mockRejectedValue(notADatabase());
|
|
169
|
+
|
|
170
|
+
const error = await captureInitError();
|
|
171
|
+
|
|
172
|
+
expect(error).toBeInstanceOf(SqliteClientError);
|
|
173
|
+
|
|
174
|
+
expect(error.code).toBe('OFFLINE_DB_UNREADABLE');
|
|
175
|
+
// Deleting it is the caller's decision - the pending-task queue lives in there.
|
|
176
|
+
expect(deleteMocks.every((m) => m.mock.calls.length === 0)).toBe(true);
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
it('keeps the original cause on the thrown error', async () => {
|
|
180
|
+
const cause = notADatabase();
|
|
181
|
+
SqliteClient.getEncryptionKey = jest.fn().mockResolvedValue('a-stable-key');
|
|
182
|
+
jest.spyOn(SqliteClient, 'getUserPragmaVersion').mockRejectedValue(cause);
|
|
183
|
+
|
|
184
|
+
const error = await captureInitError();
|
|
185
|
+
|
|
186
|
+
expect(error).toBeInstanceOf(SqliteClientError);
|
|
187
|
+
|
|
188
|
+
expect(error.cause).toBe(cause);
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
it('throws even with no encryption configured', async () => {
|
|
192
|
+
// Turning encryption off leaves an encrypted file and no key to read it. Simply
|
|
193
|
+
// reporting failure would leave offline support uninitialized forever, so the
|
|
194
|
+
// caller is told and can delete it.
|
|
195
|
+
jest.spyOn(SqliteClient, 'getUserPragmaVersion').mockRejectedValue(notADatabase());
|
|
196
|
+
|
|
197
|
+
const error = await captureInitError();
|
|
198
|
+
|
|
199
|
+
expect(error).toBeInstanceOf(SqliteClientError);
|
|
200
|
+
|
|
201
|
+
expect(error.code).toBe('OFFLINE_DB_UNREADABLE');
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
it('does not throw on a transient failure', async () => {
|
|
205
|
+
SqliteClient.getEncryptionKey = jest.fn().mockResolvedValue('a-stable-key');
|
|
206
|
+
jest
|
|
207
|
+
.spyOn(SqliteClient, 'getUserPragmaVersion')
|
|
208
|
+
.mockRejectedValue(new Error('Query failed: Error: database is locked'));
|
|
209
|
+
|
|
210
|
+
await expect(SqliteClient.initializeDatabase()).resolves.toBe(false);
|
|
211
|
+
});
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
describe('isUnreadableDbError', () => {
|
|
215
|
+
it.each([
|
|
216
|
+
'file is not a database',
|
|
217
|
+
'SQLite error code: 26',
|
|
218
|
+
'SQLite code:11',
|
|
219
|
+
'NOTADB',
|
|
220
|
+
'SQLITE_CORRUPT',
|
|
221
|
+
'database disk image is malformed',
|
|
222
|
+
'file is encrypted or is not a database',
|
|
223
|
+
])('treats %p as unreadable', (message) => {
|
|
224
|
+
expect(SqliteClient.isUnreadableDbError(new Error(message))).toBe(true);
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
it.each([
|
|
228
|
+
'database is locked',
|
|
229
|
+
'SQLITE_BUSY',
|
|
230
|
+
'SQLITE_LOCKED',
|
|
231
|
+
'disk I/O error',
|
|
232
|
+
'SQLITE_IOERR',
|
|
233
|
+
'unable to open database file',
|
|
234
|
+
'SQLITE_CANTOPEN',
|
|
235
|
+
'out of memory',
|
|
236
|
+
'attempt to write a readonly database',
|
|
237
|
+
'DB is not open or initialized.',
|
|
238
|
+
'Please install "@op-engineering/op-sqlite" package to enable offline support',
|
|
239
|
+
])('does not treat %p as unreadable', (message) => {
|
|
240
|
+
expect(SqliteClient.isUnreadableDbError(new Error(message))).toBe(false);
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
it('lets a transient reason win when both are present in one message', () => {
|
|
244
|
+
// Pins the precedence rule: a message that could be read either way must not
|
|
245
|
+
// trigger a wipe. Guessing wrong in this direction destroys a good database.
|
|
246
|
+
expect(
|
|
247
|
+
SqliteClient.isUnreadableDbError(
|
|
248
|
+
new Error('unable to open database file: file is not a database'),
|
|
249
|
+
),
|
|
250
|
+
).toBe(false);
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
it('handles non-Error throwables', () => {
|
|
254
|
+
expect(SqliteClient.isUnreadableDbError('file is not a database')).toBe(true);
|
|
255
|
+
expect(SqliteClient.isUnreadableDbError(undefined)).toBe(false);
|
|
256
|
+
});
|
|
257
|
+
});
|
|
258
|
+
});
|
|
@@ -2,11 +2,14 @@ import Database from 'better-sqlite3';
|
|
|
2
2
|
|
|
3
3
|
import { tables } from '../store/schema';
|
|
4
4
|
|
|
5
|
+
// Must match the name used by mock-builders/DB/mock.ts.
|
|
6
|
+
const testDbName = `foobar-${process.env.JEST_WORKER_ID ?? '0'}.db`;
|
|
7
|
+
|
|
5
8
|
export class BetterSqlite {
|
|
6
9
|
db = null;
|
|
7
10
|
|
|
8
11
|
static openDB = () => {
|
|
9
|
-
this.db = new Database(
|
|
12
|
+
this.db = new Database(testDbName);
|
|
10
13
|
};
|
|
11
14
|
|
|
12
15
|
static closeDB = () => {
|
package/src/version.json
CHANGED