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
@@ -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
- static initializeDatabase = async () => {
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,
@@ -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('foobar.db');
12
+ this.db = new Database(testDbName);
10
13
  };
11
14
 
12
15
  static closeDB = () => {
package/src/version.json CHANGED
@@ -1,3 +1,3 @@
1
1
  {
2
- "version": "8.13.19"
2
+ "version": "8.14.0"
3
3
  }