diff --git a/.eslintrc.js b/.eslintrc.js index d4c04cb38e2..72e380628b0 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -1,3 +1,22 @@ +// Keep the database engine behind the facade. Everything outside app/lib/database/ must import +// from app/lib/database/facade — never the raw engine or the facade's internal modules. The +// migration reader and driver live inside app/lib/database/ and are excluded below. +const reactDefaultImport = { + name: 'react', + importNames: ['default'], + message: 'Import specific named exports from React instead.' +}; +const facadeOnlyPatterns = [ + { + group: ['@nozbe/watermelondb', '@nozbe/watermelondb/**', 'expo-sqlite', 'expo-sqlite/**', 'drizzle-orm', 'drizzle-orm/**'], + message: 'Do not import the database engine directly. Use the facade at app/lib/database/facade.' + }, + { + group: ['**/database/facade/*'], + message: 'Import from the facade barrel (app/lib/database/facade), not its internal modules.' + } +]; + module.exports = { settings: { 'import/resolver': { @@ -165,6 +184,20 @@ module.exports = { env: { 'react-native/react-native': true } + }, + { + files: ['app/**/*.js'], + excludedFiles: ['app/lib/database/**'], + rules: { + 'no-restricted-imports': ['error', { paths: [reactDefaultImport], patterns: facadeOnlyPatterns }] + } + }, + { + files: ['app/**/*.{ts,tsx}'], + excludedFiles: ['app/lib/database/**'], + rules: { + '@typescript-eslint/no-restricted-imports': ['error', { paths: [reactDefaultImport], patterns: facadeOnlyPatterns }] + } } ] }; diff --git a/android/app/build.gradle b/android/app/build.gradle index f183eb60e8a..e1061b4fe49 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -141,6 +141,11 @@ dependencies { // For SecureKeystore (EncryptedSharedPreferences) implementation 'androidx.security:security-crypto:1.1.0' + // SQLCipher for reading encrypted databases from the FCM notification path. + // Version locked to match expo-sqlite 16.0.10's vendored SQLCipher 4.7.0. + // @aar ensures Gradle unpacks the AAR (ships libsqlcipher.so for all ABIs). + implementation 'net.zetetic:sqlcipher-android:4.7.0@aar' + testImplementation 'junit:junit:4.13.2' testImplementation 'org.robolectric:robolectric:4.14.1' testImplementation 'com.squareup.okhttp3:mockwebserver:4.9.2' diff --git a/android/app/src/main/java/chat/rocket/reactnative/MainApplication.kt b/android/app/src/main/java/chat/rocket/reactnative/MainApplication.kt index 10da15e8945..c9ee9d085a9 100644 --- a/android/app/src/main/java/chat/rocket/reactnative/MainApplication.kt +++ b/android/app/src/main/java/chat/rocket/reactnative/MainApplication.kt @@ -17,6 +17,7 @@ import com.nozbe.watermelondb.jsi.WatermelonDBJSIPackage; import com.bugsnag.android.Bugsnag import expo.modules.ApplicationLifecycleDispatcher import chat.rocket.reactnative.networking.SSLPinningTurboPackage; +import chat.rocket.reactnative.storage.DatabaseKeyStoreTurboPackage; import chat.rocket.reactnative.storage.MMKVKeyManager; import chat.rocket.reactnative.storage.SecureStoragePackage; import chat.rocket.reactnative.storage.DatabaseKeyStoreTurboPackage; @@ -51,6 +52,7 @@ open class MainApplication : Application(), ReactApplication { add(PushNotificationTurboPackage()) add(VoipTurboPackage()) add(SecureStoragePackage()) + add(DatabaseKeyStoreTurboPackage()) add(InvertedScrollPackage()) add(ExternalInputPackage()) } diff --git a/android/app/src/main/java/chat/rocket/reactnative/notification/Encryption.java b/android/app/src/main/java/chat/rocket/reactnative/notification/Encryption.java index 7b6d9d5369d..7bbf66648d6 100644 --- a/android/app/src/main/java/chat/rocket/reactnative/notification/Encryption.java +++ b/android/app/src/main/java/chat/rocket/reactnative/notification/Encryption.java @@ -12,7 +12,8 @@ import chat.rocket.mobilecrypto.algorithms.AESCrypto; import chat.rocket.mobilecrypto.algorithms.RSACrypto; import chat.rocket.mobilecrypto.algorithms.CryptoUtils; -import com.nozbe.watermelondb.WMDatabase; +import chat.rocket.reactnative.storage.DatabaseKeyStoreModule; +import net.zetetic.database.sqlcipher.SQLiteDatabase; import java.security.SecureRandom; import java.util.Arrays; @@ -102,6 +103,10 @@ class RoomKeyResult { } class Encryption { + static { + System.loadLibrary("sqlcipher"); + } + static class EncryptionContent { String algorithm; String ciphertext; @@ -172,55 +177,102 @@ private ParsedMessage parseMessage(Ejson.Content content) { } public Room readRoom(final Ejson ejson, Context context) { - String dbName = getDatabaseName(ejson.serverURL(), context); - WMDatabase db = null; + String dbName = deriveDbName(ejson.serverURL()); + String dbPath = context.getFilesDir().getAbsolutePath() + "/SQLite/" + dbName; + + // Read key + salt from the AndroidKeyStore-backed store. + // Storage keys match JS KEY_PREFIX / SALT_PREFIX in keyService.ts. + String storageKey = "db_key_v1:" + dbName; + String saltStorageKey = "db_salt_v1:" + dbName; + String keyHex; + String saltHex; + try { + keyHex = DatabaseKeyStoreModule.getItemInternal(context, storageKey); + } catch (Exception e) { + Log.w(TAG, "Could not read encryption key for " + dbName + " — cannot read room", e); + return null; + } + if (keyHex == null) { + Log.w(TAG, "No encryption key found for " + dbName + " — cannot read room"); + return null; + } + try { + saltHex = DatabaseKeyStoreModule.getItemInternal(context, saltStorageKey); + } catch (Exception e) { + Log.w(TAG, "Could not read cipher salt for " + dbName + " — cannot read room", e); + return null; + } + if (saltHex == null) { + Log.w(TAG, "No cipher salt found for " + dbName + " — cannot read room"); + return null; + } + // Raw-key string form: "x'<64 hex>'" — skips PBKDF2, matches the JS driver. + // Never use the byte[] overload: it silently PBKDF2-derives and produces + // "file is not a database" even when the bytes match. + String rawKey = "x'" + keyHex + "'"; + + SQLiteDatabase db = null; try { - db = WMDatabase.getInstance(dbName, context); - String[] queryArgs = {ejson.rid}; - - Cursor cursor = db.rawQuery("SELECT * FROM subscriptions WHERE id == ? LIMIT 1", queryArgs); - - if (cursor.getCount() == 0) { - cursor.close(); - return null; - } - - cursor.moveToFirst(); - int e2eKeyColumnIndex = cursor.getColumnIndex("e2e_key"); - int encryptedColumnIndex = cursor.getColumnIndex("encrypted"); - - if (e2eKeyColumnIndex == -1) { - Log.e(TAG, "e2e_key column not found in subscriptions table"); - cursor.close(); - return null; - } - - String e2eKey = cursor.getString(e2eKeyColumnIndex); - Boolean encrypted = encryptedColumnIndex != -1 && cursor.getInt(encryptedColumnIndex) > 0; - cursor.close(); - - return new Room(e2eKey, encrypted); + db = SQLiteDatabase.openDatabase(dbPath, rawKey, null, SQLiteDatabase.OPEN_READONLY, null); + + // Mirror the JS driver's open PRAGMAs (connection.ts applyOpenPragmas): + // cipher_plaintext_header_size = 32 — the driver exposes a 32-byte plaintext header + // so iOS grants the background idle-WAL exemption (0xdead10cc); the reader must + // set this too or SQLCipher will attempt to decrypt the header and fail. + // cipher_salt — with a plaintext header SQLCipher no longer stores the salt in the + // file; it must be supplied from the same keychain entry the JS driver wrote. + // busy_timeout — mandatory multi-process WAL safety. + db.execSQL("PRAGMA cipher_plaintext_header_size = 32;"); + db.execSQL("PRAGMA cipher_salt = \"x'" + saltHex + "'\";"); + db.execSQL("PRAGMA busy_timeout = 500;"); + + Cursor cursor = db.rawQuery("SELECT * FROM subscriptions WHERE rid = ? LIMIT 1", new String[]{ejson.rid}); + try { + if (cursor.getCount() == 0) { + return null; + } + + cursor.moveToFirst(); + int e2eKeyColumnIndex = cursor.getColumnIndex("e2e_key"); + int encryptedColumnIndex = cursor.getColumnIndex("encrypted"); + + if (e2eKeyColumnIndex == -1) { + Log.e(TAG, "e2e_key column not found in subscriptions table"); + return null; + } + + String e2eKey = cursor.getString(e2eKeyColumnIndex); + Boolean encrypted = encryptedColumnIndex != -1 && cursor.getInt(encryptedColumnIndex) > 0; + return new Room(e2eKey, encrypted); + } finally { + cursor.close(); + } } catch (Exception e) { Log.e(TAG, "Error reading room", e); return null; } finally { - if (db != null) { + if (db != null && db.isOpen()) { db.close(); } } } - private String getDatabaseName(String serverUrl, Context context) { - // Match JS WatermelonDB naming: strip scheme, replace '/' with '.', and append one ".db". - String name = serverUrl.replaceFirst("^(\\w+:)?//", "").replace("/", "."); - name += ".db"; - - // Important: return just the name (not an absolute path). WMDatabase will resolve and append its own ".db" internally, - // so the physical file becomes "*.db.db", matching the JS adapter. - return name; + /** + * Derives the clean database filename from a server URL. + * Matches the JS `deriveServerDbName` in connection.ts: + * strip trailing slashes → strip scheme → replace '/' with '_' → append ".db" + */ + private static String deriveDbName(String serverUrl) { + // Strip trailing slashes + String s = serverUrl.replaceAll("/+$", ""); + // Strip scheme ("https://", "http://", or bare "//") + s = s.replaceFirst("^(\\w+:)?//", ""); + // Replace remaining slashes with underscores (matches JS deriveServerDbName) + s = s.replace("/", "_"); + return s + ".db"; } public String readUserKey(final Ejson ejson) throws Exception { diff --git a/app/containers/Avatar/useAvatarETag.ts b/app/containers/Avatar/useAvatarETag.ts index 4b8a2596c91..10f12a53421 100644 --- a/app/containers/Avatar/useAvatarETag.ts +++ b/app/containers/Avatar/useAvatarETag.ts @@ -1,7 +1,7 @@ -import { Q } from '@nozbe/watermelondb'; import { useEffect, useState } from 'react'; import { type Observable, type Subscription } from 'rxjs'; +import { Q } from '../../lib/database/facade'; import { type TLoggedUserModel, type TSubscriptionModel, type TUserModel } from '../../definitions'; import database from '../../lib/database'; diff --git a/app/containers/MessageComposer/MessageComposer.tsx b/app/containers/MessageComposer/MessageComposer.tsx index f1eeeef9c63..e802269db0b 100644 --- a/app/containers/MessageComposer/MessageComposer.tsx +++ b/app/containers/MessageComposer/MessageComposer.tsx @@ -1,9 +1,9 @@ import { type ReactElement, type Ref, useRef, useImperativeHandle } from 'react'; import { AccessibilityInfo, findNodeHandle, type LayoutChangeEvent } from 'react-native'; import { useBackHandler } from '@react-native-community/hooks'; -import { Q } from '@nozbe/watermelondb'; import Animated, { useAnimatedStyle, useSharedValue } from 'react-native-reanimated'; +import { Q } from '../../lib/database/facade'; import { useRoomContext } from '../../views/RoomView/context'; import { Autocomplete } from './components'; import { MIN_HEIGHT } from './constants'; diff --git a/app/containers/MessageComposer/components/SendThreadToChannel.tsx b/app/containers/MessageComposer/components/SendThreadToChannel.tsx index b9e32127836..8c769f09008 100644 --- a/app/containers/MessageComposer/components/SendThreadToChannel.tsx +++ b/app/containers/MessageComposer/components/SendThreadToChannel.tsx @@ -2,8 +2,8 @@ import { TouchableWithoutFeedback } from 'react-native-gesture-handler'; import { StyleSheet, Text } from 'react-native'; import { useEffect, useRef, type ReactElement } from 'react'; import { type Subscription } from 'rxjs'; -import { Q } from '@nozbe/watermelondb'; +import { Q } from '../../../lib/database/facade'; import { useRoomContext } from '../../../views/RoomView/context'; import { useAlsoSendThreadToChannel, useMessageComposerApi } from '../context'; import { CustomIcon } from '../../CustomIcon'; diff --git a/app/containers/MessageComposer/hooks/useAutocomplete.ts b/app/containers/MessageComposer/hooks/useAutocomplete.ts index 906fe8d631e..ccb4f2a77a2 100644 --- a/app/containers/MessageComposer/hooks/useAutocomplete.ts +++ b/app/containers/MessageComposer/hooks/useAutocomplete.ts @@ -1,6 +1,6 @@ import { useEffect, useState } from 'react'; -import { Q } from '@nozbe/watermelondb'; +import { Q } from '../../../lib/database/facade'; import { type IAutocompleteEmoji, type IAutocompleteUserRoom, diff --git a/app/containers/MessageErrorActions.tsx b/app/containers/MessageErrorActions.tsx index 3164da0d1c2..fb469af7fd4 100644 --- a/app/containers/MessageErrorActions.tsx +++ b/app/containers/MessageErrorActions.tsx @@ -1,6 +1,6 @@ import { forwardRef, useImperativeHandle } from 'react'; -import type Model from '@nozbe/watermelondb/Model'; +import type { Model } from '../lib/database/facade'; import database from '../lib/database'; import protectedFunction from '../lib/methods/helpers/protectedFunction'; import { useActionSheet } from './ActionSheet'; diff --git a/app/definitions/IEmoji.ts b/app/definitions/IEmoji.ts index 4b025979fe6..82c4853eaf5 100644 --- a/app/definitions/IEmoji.ts +++ b/app/definitions/IEmoji.ts @@ -1,4 +1,4 @@ -import type Model from '@nozbe/watermelondb/Model'; +import type { Model } from '../lib/database/facade'; export interface IFrequentlyUsedEmoji { content: string; diff --git a/app/definitions/ILoggedUser.ts b/app/definitions/ILoggedUser.ts index 566164be974..dff27ef1ac9 100644 --- a/app/definitions/ILoggedUser.ts +++ b/app/definitions/ILoggedUser.ts @@ -1,5 +1,4 @@ -import type Model from '@nozbe/watermelondb/Model'; - +import type { Model } from '../lib/database/facade'; import { type IUserEmail, type IUserSettings } from './IUser'; import { type TUserStatus } from './TUserStatus'; diff --git a/app/definitions/IMessage.ts b/app/definitions/IMessage.ts index 756228addec..09cdd9982ea 100644 --- a/app/definitions/IMessage.ts +++ b/app/definitions/IMessage.ts @@ -1,6 +1,6 @@ -import type Model from '@nozbe/watermelondb/Model'; import { type Root } from '@rocket.chat/message-parser'; +import type { Model } from '../lib/database/facade'; import { type MessageTypeLoad } from '../lib/constants/messageTypeLoad'; import { type IAttachment } from './IAttachment'; import { type IReaction } from './IReaction'; diff --git a/app/definitions/IPermission.ts b/app/definitions/IPermission.ts index d0b6273aa04..718724335e2 100644 --- a/app/definitions/IPermission.ts +++ b/app/definitions/IPermission.ts @@ -1,4 +1,4 @@ -import type Model from '@nozbe/watermelondb/Model'; +import type { Model } from '../lib/database/facade'; export interface IPermission { _id: string; diff --git a/app/definitions/IRole.ts b/app/definitions/IRole.ts index 11a9468ad15..760a7c44564 100644 --- a/app/definitions/IRole.ts +++ b/app/definitions/IRole.ts @@ -1,4 +1,4 @@ -import type Model from '@nozbe/watermelondb/Model'; +import type { Model } from '../lib/database/facade'; export interface IRole { id: string; diff --git a/app/definitions/IRoom.ts b/app/definitions/IRoom.ts index e77221f13bc..131e0a7cc45 100644 --- a/app/definitions/IRoom.ts +++ b/app/definitions/IRoom.ts @@ -1,5 +1,4 @@ -import type Model from '@nozbe/watermelondb/Model'; - +import type { Model } from '../lib/database/facade'; import { type IMessage } from './IMessage'; import { type IRocketChatRecord } from './IRocketChatRecord'; import { type IServedBy } from './IServedBy'; diff --git a/app/definitions/IServer.ts b/app/definitions/IServer.ts index 418b11f1de3..147300fe43b 100644 --- a/app/definitions/IServer.ts +++ b/app/definitions/IServer.ts @@ -1,5 +1,4 @@ -import type Model from '@nozbe/watermelondb/Model'; - +import type { Model } from '../lib/database/facade'; import { type IEnterpriseModules } from '../reducers/enterpriseModules'; export type TSVStatus = 'supported' | 'expired' | 'warn'; diff --git a/app/definitions/IServerHistory.ts b/app/definitions/IServerHistory.ts index 00d2ce1a149..abe9476792a 100644 --- a/app/definitions/IServerHistory.ts +++ b/app/definitions/IServerHistory.ts @@ -1,4 +1,4 @@ -import type Model from '@nozbe/watermelondb/Model'; +import type { Model } from '../lib/database/facade'; export interface IServerHistory { id: string; diff --git a/app/definitions/ISettings.ts b/app/definitions/ISettings.ts index 2901311de6e..6c9995bea97 100644 --- a/app/definitions/ISettings.ts +++ b/app/definitions/ISettings.ts @@ -1,4 +1,4 @@ -import type Model from '@nozbe/watermelondb/Model'; +import type { Model } from '../lib/database/facade'; export interface ISettings { id: string; diff --git a/app/definitions/ISlashCommand.ts b/app/definitions/ISlashCommand.ts index 4f121f1e765..bc0aa1e5221 100644 --- a/app/definitions/ISlashCommand.ts +++ b/app/definitions/ISlashCommand.ts @@ -1,4 +1,4 @@ -import type Model from '@nozbe/watermelondb/Model'; +import type { Model } from '../lib/database/facade'; export interface ISlashCommand { id: string; diff --git a/app/definitions/ISubscription.ts b/app/definitions/ISubscription.ts index 6bbd836889b..2aae7fcbd63 100644 --- a/app/definitions/ISubscription.ts +++ b/app/definitions/ISubscription.ts @@ -1,6 +1,4 @@ -import type Model from '@nozbe/watermelondb/Model'; -import type Relation from '@nozbe/watermelondb/Relation'; - +import type { Model, Relation } from '../lib/database/facade'; import { type ILastMessage, type TMessageModel } from './IMessage'; import { type IRocketChatRecord } from './IRocketChatRecord'; import { type IOmnichannelSource, type RoomID, type RoomType, type TUserWaitingForE2EKeys } from './IRoom'; diff --git a/app/definitions/IThread.ts b/app/definitions/IThread.ts index 76704f3bec6..b591773fc2a 100644 --- a/app/definitions/IThread.ts +++ b/app/definitions/IThread.ts @@ -1,6 +1,6 @@ -import type Model from '@nozbe/watermelondb/Model'; import { type Root } from '@rocket.chat/message-parser'; +import type { Model } from '../lib/database/facade'; import { type IAttachment } from './IAttachment'; import { type IMessage, type IUserChannel, type IUserMention, type IUserMessage } from './IMessage'; import { type IUrl } from './IUrl'; diff --git a/app/definitions/IThreadMessage.ts b/app/definitions/IThreadMessage.ts index 5f94b586b45..bfb2e1c372f 100644 --- a/app/definitions/IThreadMessage.ts +++ b/app/definitions/IThreadMessage.ts @@ -1,5 +1,4 @@ -import type Model from '@nozbe/watermelondb/Model'; - +import type { Model } from '../lib/database/facade'; import { type IMessage } from './IMessage'; export interface IThreadMessage extends IMessage { diff --git a/app/definitions/IUpload.ts b/app/definitions/IUpload.ts index 54e02580bdb..c333ac730a3 100644 --- a/app/definitions/IUpload.ts +++ b/app/definitions/IUpload.ts @@ -1,4 +1,4 @@ -import type Model from '@nozbe/watermelondb/Model'; +import type { Model } from '../lib/database/facade'; export interface IUpload { id?: string; diff --git a/app/definitions/IUser.ts b/app/definitions/IUser.ts index dba65151567..ec14f28cd04 100644 --- a/app/definitions/IUser.ts +++ b/app/definitions/IUser.ts @@ -1,5 +1,4 @@ -import type Model from '@nozbe/watermelondb/Model'; - +import type { Model } from '../lib/database/facade'; import { type TUserStatus } from './TUserStatus'; import { type IRocketChatRecord } from './IRocketChatRecord'; import { type ILoggedUser } from './ILoggedUser'; diff --git a/app/lib/database/driver/__tests__/connection.test.ts b/app/lib/database/driver/__tests__/connection.test.ts index a6b2002dd3b..27fa7d093cf 100644 --- a/app/lib/database/driver/__tests__/connection.test.ts +++ b/app/lib/database/driver/__tests__/connection.test.ts @@ -42,11 +42,24 @@ jest.mock('expo-sqlite', () => ({ addDatabaseChangeListener: jest.fn(() => ({ remove: jest.fn() })) })); +const createdDirs: string[] = []; + jest.mock('expo-file-system', () => ({ Paths: { appleSharedContainers: { 'group.ios.chat.rocket': { uri: '/fake/app-group/' } } + }, + // Minimal Directory stub: joins uris like the real constructor and records create() calls + Directory: class { + uri: string; + exists = false; + constructor(...uris: string[]) { + this.uri = uris.join('/').replace(/\/+/g, '/'); + } + create() { + createdDirs.push(this.uri); + } } })); @@ -61,6 +74,11 @@ jest.mock('drizzle-orm/expo-sqlite', () => ({ drizzle: jest.fn(() => ({})) })); +// Migrator — DDL application is covered on-device; here we only assert the open sequence +jest.mock('drizzle-orm/expo-sqlite/migrator', () => ({ + migrate: jest.fn(async () => {}) +})); + // React Native Platform jest.mock('react-native', () => ({ Platform: { OS: 'ios' } @@ -186,6 +204,21 @@ describe('open sequence', () => { }); }); +// --------------------------------------------------------------------------- +// iOS directory isolation (Slice 0 — collision fix) +// --------------------------------------------------------------------------- + +describe('iOS directory isolation', () => { + it('creates and opens DBs in the App Group SQLite subdirectory, not the container root', async () => { + // resolved once at module load — proves new DBs avoid the legacy plaintext files at the root + expect(createdDirs).toContain('/fake/app-group/SQLite'); + + await openServersDb(); + const [, , dir] = (openDatabaseAsync as jest.Mock).mock.calls[0]; + expect(dir).toBe('/fake/app-group/SQLite'); + }); +}); + // --------------------------------------------------------------------------- // Registry // --------------------------------------------------------------------------- diff --git a/app/lib/database/driver/__tests__/keyStore.test.ts b/app/lib/database/driver/__tests__/keyStore.test.ts new file mode 100644 index 00000000000..72484c345f7 --- /dev/null +++ b/app/lib/database/driver/__tests__/keyStore.test.ts @@ -0,0 +1,73 @@ +/** + * keyStore.ts tests — verifies the native module shim wiring. + * + * Covers: + * - installNativeKeychainShim delegates getItem/setItem/removeItem to the native module + * - Missing native module throws a clear error (not an obscure undefined crash) + */ + +import { installKeychainShim } from '../keyService'; +import { installNativeKeychainShim } from '../keyStore'; +import NativeDatabaseKeyStore from '../../../native/NativeDatabaseKeyStore'; + +// --------------------------------------------------------------------------- +// Mocks +// --------------------------------------------------------------------------- + +jest.mock('../../../native/NativeDatabaseKeyStore', () => ({ + __esModule: true, + default: { + getItem: jest.fn(async (_key: string): Promise => null), + setItem: jest.fn(async (_key: string, _value: string): Promise => undefined), + removeItem: jest.fn(async (_key: string): Promise => undefined) + } +})); + +jest.mock('../keyService', () => ({ + installKeychainShim: jest.fn() +})); + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +describe('installNativeKeychainShim', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('calls installKeychainShim with an object that delegates to the native module', async () => { + installNativeKeychainShim(); + + expect(installKeychainShim).toHaveBeenCalledTimes(1); + const shim = (installKeychainShim as jest.Mock).mock.calls[0][0]; + + // getItem delegates + const mockNative = NativeDatabaseKeyStore!; + (mockNative.getItem as jest.Mock).mockResolvedValueOnce('abc123'); + const result = await shim.getItem('db_key_v1:test.db'); + expect(mockNative.getItem).toHaveBeenCalledWith('db_key_v1:test.db'); + expect(result).toBe('abc123'); + + // setItem delegates + await shim.setItem('db_key_v1:test.db', 'hexvalue'); + expect(mockNative.setItem).toHaveBeenCalledWith('db_key_v1:test.db', 'hexvalue'); + + // removeItem delegates + await shim.removeItem('db_key_v1:test.db'); + expect(mockNative.removeItem).toHaveBeenCalledWith('db_key_v1:test.db'); + }); + + it('throws a descriptive error when the native module is not linked', () => { + jest.resetModules(); + jest.doMock('../../../native/NativeDatabaseKeyStore', () => ({ + __esModule: true, + default: null + })); + jest.doMock('../keyService', () => ({ installKeychainShim: jest.fn() })); + + // Load a fresh copy of the module without the native module present + const { installNativeKeychainShim: freshInstall } = require('../keyStore'); + expect(() => freshInstall()).toThrow('DatabaseKeyStore native module not found'); + }); +}); diff --git a/app/lib/database/driver/connection.ts b/app/lib/database/driver/connection.ts index dcbc8a84a29..126c94d0531 100644 --- a/app/lib/database/driver/connection.ts +++ b/app/lib/database/driver/connection.ts @@ -35,7 +35,7 @@ import { Platform } from 'react-native'; import { openDatabaseAsync, deleteDatabaseAsync, type SQLiteDatabase } from 'expo-sqlite'; import { drizzle, type ExpoSQLiteDatabase } from 'drizzle-orm/expo-sqlite'; -import { Paths } from 'expo-file-system'; +import { Directory, Paths } from 'expo-file-system'; import * as appSchema from './schema/app'; import * as serversSchema from './schema/servers'; @@ -66,6 +66,9 @@ export interface DbHandle { const APP_GROUP_ID = 'group.ios.chat.rocket'; +/** iOS subdirectory for new encrypted DBs, isolating them from legacy files at the container root. */ +const DB_SUBDIRECTORY = 'SQLite'; + /** The single servers/global DB name (no server URL involved). */ export const DEFAULT_DB_NAME = 'default.db'; @@ -88,7 +91,7 @@ export function deriveServerDbName(serverUrl: string): string { // --------------------------------------------------------------------------- /** - * Returns the iOS App Group container URI for database placement. + * Returns the SQLite/ subdirectory of the iOS App Group container for new encrypted DBs, isolating them from legacy plaintext WatermelonDB files at the container root. * Falls back to undefined (expo-sqlite default dir) when: * - running on Android * - the container is unavailable (simulator builds without entitlement, unit tests) @@ -108,8 +111,12 @@ function resolveDbDirectory(): string | undefined { ); return undefined; } - // uri may have a trailing slash; expo-sqlite wants a directory path - return container.uri.replace(/\/$/, ''); + const sqliteDir = new Directory(container.uri, DB_SUBDIRECTORY); + if (!sqliteDir.exists) { + sqliteDir.create({ intermediates: true, idempotent: true }); + } + // uri may carry a trailing slash; expo-sqlite wants a bare directory path + return sqliteDir.uri.replace(/\/$/, ''); } catch (e) { console.warn( '[db/connection] Failed to resolve App Group path:', @@ -200,9 +207,11 @@ function openDb(dbName: string, kind: K): Promise> _inflight.set(dbName, promise); // Cleanup inflight entry regardless of outcome. The .catch here silences the secondary // rejection on the finally-chained promise — the real rejection propagates via `promise`. - promise.finally(() => { - _inflight.delete(dbName); - }).catch(() => {}); + promise + .finally(() => { + _inflight.delete(dbName); + }) + .catch(() => {}); return promise as Promise>; } diff --git a/app/lib/database/driver/keyService.ts b/app/lib/database/driver/keyService.ts index 7fa88af26bc..d0d04e79f32 100644 --- a/app/lib/database/driver/keyService.ts +++ b/app/lib/database/driver/keyService.ts @@ -93,7 +93,7 @@ const _getOrCreateInflight = new Map>(); * Validates both stored values (corrupt → throw) and generated values (bad bridge → throw). * Neither the stored value nor the generated value ever appears in thrown error messages. */ -async function getOrCreate(sk: string, byteLen: number, hexLen: number, label: string): Promise { +function getOrCreate(sk: string, byteLen: number, hexLen: number, label: string): Promise { const inflight = _getOrCreateInflight.get(sk); if (inflight) return inflight; @@ -123,9 +123,11 @@ async function getOrCreate(sk: string, byteLen: number, hexLen: number, label: s _getOrCreateInflight.set(sk, promise); // Cleanup regardless of outcome. The .catch silences the secondary rejection on the // finally-chained promise — the real rejection propagates via `promise`. - promise.finally(() => { - _getOrCreateInflight.delete(sk); - }).catch(() => {}); + promise + .finally(() => { + _getOrCreateInflight.delete(sk); + }) + .catch(() => {}); return promise; } diff --git a/app/lib/database/driver/keyStore.ts b/app/lib/database/driver/keyStore.ts new file mode 100644 index 00000000000..eea58d5a4f6 --- /dev/null +++ b/app/lib/database/driver/keyStore.ts @@ -0,0 +1,41 @@ +/** + * Thin JS shim that wraps the native DatabaseKeyStore TurboModule and satisfies + * IKeychainShim, then installs it into keyService via installKeychainShim. + * + * Call installNativeKeychainShim() once before any database is opened. + * The right place is app startup, before the first call to openServersDb / + * openServerDb. The driver facade will call this as part of its own init; + * until that ticket lands, call it from the app entry point (app/index.tsx). + */ + +import NativeDatabaseKeyStore from '../../native/NativeDatabaseKeyStore'; +import { installKeychainShim, type IKeychainShim } from './keyService'; + +let _installed = false; + +function getNativeModule() { + if (!NativeDatabaseKeyStore) { + throw new Error('DatabaseKeyStore native module not found — ensure the module is linked and the app is rebuilt'); + } + return NativeDatabaseKeyStore; +} + +function makeNativeShim(): IKeychainShim { + const native = getNativeModule(); + return { + getItem: (key: string) => native.getItem(key), + setItem: (key: string, value: string) => native.setItem(key, value), + removeItem: (key: string) => native.removeItem(key) + }; +} + +/** + * Installs the native Keychain/Keystore shim into the key service. + * Must be called once before any database is opened. + * No-op after the first successful install. + */ +export function installNativeKeychainShim(): void { + if (_installed) return; + installKeychainShim(makeNativeShim()); + _installed = true; +} diff --git a/app/lib/database/facade/Collection.ts b/app/lib/database/facade/Collection.ts new file mode 100644 index 00000000000..387bbcf9b2a --- /dev/null +++ b/app/lib/database/facade/Collection.ts @@ -0,0 +1,142 @@ +/** + * Collection — facade over a single Drizzle table. + * Exposes query/find/create/prepareCreate, matching the WMDB Collection API. + */ + +import type { Observable } from 'rxjs'; +import { eq, sql, getTableColumns } from 'drizzle-orm'; +import type { SQLiteTable } from 'drizzle-orm/sqlite-core'; + +import type { DbHandle } from '../driver/connection'; +import type { Database } from './Database'; +import type { TableSchema, RawRecord } from './schema'; +import { sanitizedRaw } from './schema'; +import { type Model } from './Model'; +import { Query } from './Query'; +import type * as Q from './Q'; +import { translateClauses } from './translate'; +import { observeTable, observeTableWithColumns } from './observe'; + +export class Collection { + readonly table: string; + readonly schema: TableSchema; + readonly _handle: DbHandle; + // Back-ref to the Database, set after construction to avoid circular import ordering issues + _db!: Database; + + /** The Drizzle table object for this collection. */ + private _drizzleTable: SQLiteTable; + + /** Model constructor for this collection. */ + private _ModelClass: new (col: Collection, raw: RawRecord) => M; + + constructor( + table: string, + schema: TableSchema, + drizzleTable: SQLiteTable, + handle: DbHandle, + ModelClass: new (col: Collection, raw: RawRecord) => M + ) { + this.table = table; + this.schema = schema; + this._drizzleTable = drizzleTable; + this._handle = handle; + this._ModelClass = ModelClass; + } + + /** Wraps this Collection as the ICollection interface Model expects. */ + get _collection(): Collection { + return this; + } + + // --------------------------------------------------------------------------- + // Internal fetch helpers (synchronous — Drizzle expo-sqlite is sync) + // --------------------------------------------------------------------------- + + /** Synchronous full fetch — used by observe and find. */ + _fetchSync(filter?: Record): M[] { + const { db } = this._handle; + const columns = getTableColumns(this._drizzleTable); + let q = db.select().from(this._drizzleTable as never); + if (filter?.id !== undefined) { + const idCol = columns.id; + if (idCol) { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + q = (q as any).where(eq(idCol, filter.id)); + } + } + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const rows: RawRecord[] = (q as any).all() as RawRecord[]; + return rows.map(raw => new this._ModelClass(this, raw)); + } + + /** Synchronous fetch with clauses. */ + _fetchAll(clauses: Q.Clause[]): M[] { + const { where, orderBy, limit, offset } = translateClauses(clauses, this._drizzleTable); + const { db } = this._handle; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + let q: any = db.select().from(this._drizzleTable as never); + if (where) q = q.where(where); + if (orderBy.length > 0) q = q.orderBy(...orderBy); + if (limit !== undefined) q = q.limit(limit); + if (offset !== undefined) q = q.offset(offset); + const rows: RawRecord[] = q.all() as RawRecord[]; + return rows.map(raw => new this._ModelClass(this, raw)); + } + + /** Synchronous count with clauses. */ + _fetchCount(clauses: Q.Clause[]): number { + const { where } = translateClauses(clauses, this._drizzleTable); + const { db } = this._handle; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + let q: any = db.select({ c: sql`count(*)` }).from(this._drizzleTable as never); + if (where) q = q.where(where); + const rows = q.all() as { c: number }[]; + return Number(rows[0]?.c ?? 0); + } + + _observe(clauses: Q.Clause[]): Observable { + return observeTable(this._handle, this.table, () => this._fetchAll(clauses)) as unknown as Observable; + } + + _observeWithColumns(clauses: Q.Clause[], cols: string[]): Observable { + return observeTableWithColumns(this._handle, this.table, cols, () => this._fetchAll(clauses)) as unknown as Observable; + } + + // --------------------------------------------------------------------------- + // Public API + // --------------------------------------------------------------------------- + + /** Build a Query for this collection. Accepts spread clauses or a single array (WMDB parity). */ + query(...clauses: (Q.Clause | Q.Clause[])[]): Query { + return new Query(this, clauses.flat()); + } + + /** Find a record by id. Rejects when missing (WMDB parity). */ + find(id: string): Promise { + const rows = this._fetchSync({ id }); + if (rows.length === 0) { + return Promise.reject(new Error(`Record not found in '${this.table}' with id '${id}'`)); + } + return Promise.resolve(rows[0]); + } + + /** Prepare a new record without persisting it. Tag _pendingOp = 'create'. */ + prepareCreate(fn: (record: M) => void): M { + // Start with a raw where id will be set by the fn or sanitizedRaw + const raw = sanitizedRaw({}, this.schema); + const model = new this._ModelClass(this, raw); + fn(model); + model._pendingOp = 'create'; + return model; + } + + /** Create a record immediately (write + batch). */ + create(fn: (record: M) => void): Promise { + return this._db.write(async () => { + const model = this.prepareCreate(fn); + await this._db.batch(model); + return model; + }); + } +} diff --git a/app/lib/database/facade/Database.ts b/app/lib/database/facade/Database.ts new file mode 100644 index 00000000000..10158f69672 --- /dev/null +++ b/app/lib/database/facade/Database.ts @@ -0,0 +1,129 @@ +/** + * Facade Database class. + * + * Constructed from a DbHandle (from driver/connection.ts). + * Exposes: get(table) → Collection, write(fn), batch(...models), unsafeResetDatabase(). + */ + +import { eq, getTableColumns } from 'drizzle-orm'; +import type { SQLiteTable, BaseSQLiteDatabase } from 'drizzle-orm/sqlite-core'; +import type { SQLiteRunResult } from 'expo-sqlite'; + +import type { DbHandle } from '../driver/connection'; +import type { AppSchema, RawRecord } from './schema'; +import { Model, type ICollection, type PendingOp } from './Model'; +import { Collection } from './Collection'; +import { WriterQueue } from './writer'; + +/** Typed alias for synchronous Drizzle DML without schema-generic noise. */ +type SyncDb = BaseSQLiteDatabase<'sync', SQLiteRunResult, Record>; + +/** Constructor for a Model subclass, as registered per table (mirrors WMDB modelClasses). */ +export type ModelClass = new (collection: ICollection, raw: RawRecord) => Model; + +export class Database { + private _handle: DbHandle; + private _schema: AppSchema; + private _tableMap: Record; + private _modelMap: Record; + private _collections: Map = new Map(); + private _writer: WriterQueue = new WriterQueue(); + + constructor(handle: DbHandle, schema: AppSchema, tableMap: Record, modelMap: Record) { + this._handle = handle; + this._schema = schema; + this._tableMap = tableMap; + this._modelMap = modelMap; + } + + /** Get the Collection for the given WMDB table name. */ + get(table: string): Collection { + const cached = this._collections.get(table); + if (cached) return cached; + + const tableSchema = this._schema.tables[table]; + if (!tableSchema) throw new Error(`Unknown table '${table}' — not in schema`); + + const drizzleTable = this._tableMap[table]; + if (!drizzleTable) throw new Error(`No Drizzle table registered for '${table}'`); + + // Instantiate the registered subclass so its @field/@date/@json accessors are present. + const ModelClass = this._modelMap[table] ?? Model; + const col = new Collection(table, tableSchema, drizzleTable, this._handle, ModelClass); + col._db = this; + this._collections.set(table, col); + return col; + } + + /** Serialized writer queue. Only one fn runs at a time. */ + write(fn: () => Promise): Promise { + return this._writer.enqueue(fn); + } + + /** + * Execute all pending ops in ONE Drizzle transaction. + * Accepts models or arrays of models (call sites pass both). + */ + batch(...args: (Model | Model[] | null | undefined)[]): Promise { + const models: Model[] = []; + for (const arg of args) { + if (Array.isArray(arg)) { + for (const m of arg) { + if (m) models.push(m); + } + } else if (arg) { + models.push(arg); + } + } + + if (models.length === 0) return Promise.resolve(); + + const db = this._handle.db as unknown as SyncDb; + const committed: Model[] = []; + db.transaction(() => { + for (const model of models) { + const op: PendingOp | null = model._pendingOp; + if (!op) continue; + + const drizzleTable = this._tableMap[model._collection.table]; + if (!drizzleTable) throw new Error(`No Drizzle table for '${model._collection.table}'`); + + if (op === 'create') { + db.insert(drizzleTable) + .values(model._raw as never) + .run(); + } else if (op === 'update') { + const { id, ...rest } = model._raw; + db.update(drizzleTable) + .set(rest as never) + .where(eq(getTableColumns(drizzleTable).id, id)) + .run(); + } else if (op === 'destroy') { + db.delete(drizzleTable) + .where(eq(getTableColumns(drizzleTable).id, model._raw.id)) + .run(); + } + + committed.push(model); + } + }); + for (const m of committed) m._pendingOp = null; + return Promise.resolve(); + } + + /** + * Delete all rows from every table on this handle. + * Does NOT delete the database file — matches WMDB semantics for clearCache/logout. + */ + unsafeResetDatabase(): Promise { + const db = this._handle.db as unknown as SyncDb; + db.transaction(() => { + for (const drizzleTable of Object.values(this._tableMap)) { + db.delete(drizzleTable).run(); + } + }); + // Clear the collection cache so next fetch sees the empty state + this._collections.clear(); + return Promise.resolve(); + } +} diff --git a/app/lib/database/facade/Model.ts b/app/lib/database/facade/Model.ts new file mode 100644 index 00000000000..fead2df7a2c --- /dev/null +++ b/app/lib/database/facade/Model.ts @@ -0,0 +1,158 @@ +/** + * Facade Model base class. + * + * _raw IS the Drizzle row (snake_case column keys). Field getters read/write _raw directly. + * Pending ops (create/update/destroy) are tagged on _pendingOp for batch() to execute. + */ + +import type { Observable } from 'rxjs'; + +import type { DbHandle } from '../driver/connection'; +import type { Database } from './Database'; +import type { TableSchema, RawRecord } from './schema'; +import { setRawCoerced, sanitizedRaw } from './schema'; +import { observeRow } from './observe'; + +export type PendingOp = 'create' | 'update' | 'destroy'; + +// Forward declarations to avoid circular imports at class definition time. +// Collection/Database are imported lazily through the instance's _collection back-ref. +export interface ICollection { + table: string; + schema: TableSchema; + _handle: DbHandle; + _db: Database; +} + +export class Model { + // WMDB tag — used by withObservables to differentiate types + static readonly _wmelonTag = 'model'; + + /** The Drizzle row. Call sites do `record._raw = sanitizedRaw(...)` directly. */ + _raw: RawRecord; + + /** Back-ref to the Collection this model belongs to. */ + _collection: ICollection; + + /** Pending write op, consumed by batch(). */ + _pendingOp: PendingOp | null = null; + + /** Memoized date cache keyed by ms timestamp — mirrors WMDB @date behavior. */ + _dateCache: Map = new Map(); + + /** Query cache for @children. */ + _childrenQueryCache: Record = {}; + + constructor(collection: ICollection, raw: RawRecord) { + this._collection = collection; + this._raw = raw; + } + + get id(): string { + return this._raw.id as string; + } + + /** WMDB compat — call sites read `record.collection.table`. */ + get collection(): ICollection { + return this._collection; + } + + /** Used by WMDB Relation/children to resolve collections. */ + get collections(): { get: (table: string) => ICollection } { + const db = this._collection._db; + return { + get: (table: string) => db.get(table)._collection + }; + } + + /** WMDB compat alias — decorators use `this.asModel` to reach _getRaw/_setRaw. */ + get asModel(): this { + return this; + } + + // --------------------------------------------------------------------------- + // _getRaw / _setRaw + // --------------------------------------------------------------------------- + + _getRaw(column: string): unknown { + return this._raw[column]; + } + + _setRaw(column: string, value: unknown): void { + const col = this._collection.schema.columnsByName[column]; + if (col) { + setRawCoerced(this._raw, column, value, col); + } else { + // id and unknown columns assigned as-is (WMDB behavior) + this._raw[column] = value; + } + } + + // --------------------------------------------------------------------------- + // Pending ops + // --------------------------------------------------------------------------- + + /** + * Prepare a create op: new model, run populator, tag _pendingOp = 'create'. + * Returned model is NOT yet persisted — pass to batch(). + */ + static prepareCreate( + this: new (col: ICollection, raw: RawRecord) => M, + collection: ICollection, + fn: (record: M) => void + ): M { + const raw = sanitizedRaw({}, collection.schema); + const model = new this(collection, raw); + fn(model); + model._pendingOp = 'create'; + return model; + } + + /** Prepare an update: run mutator, tag _pendingOp = 'update'. */ + prepareUpdate(fn: (record: this) => void): this { + fn(this); + this._pendingOp = 'update'; + return this; + } + + /** Tag for permanent deletion in the next batch. */ + prepareDestroyPermanently(): this { + this._pendingOp = 'destroy'; + return this; + } + + /** Immediate single-op update via the database writer queue. */ + update(fn: (record: this) => void): Promise { + return this._collection._db.write(async () => { + this.prepareUpdate(fn); + await this._collection._db.batch(this); + return this; + }); + } + + /** Immediate single-op permanent delete via the database writer queue. */ + destroyPermanently(): Promise { + return this._collection._db.write(async () => { + this.prepareDestroyPermanently(); + await this._collection._db.batch(this); + return this; + }); + } + + // --------------------------------------------------------------------------- + // Observe + // --------------------------------------------------------------------------- + + /** RxJS Observable that re-emits whenever this row changes. */ + observe(): Observable { + const { _handle, table, _db: db } = this._collection; + const { id } = this; + return observeRow(_handle, table, () => { + // Re-fetch by id so observe() returns fresh data after updates + const col = db.get(table); + // Synchronous fetch: use the underlying Drizzle select + const rows = col._fetchSync({ id }); + return rows.length > 0 ? (rows[0] as this) : null; + }); + } +} diff --git a/app/lib/database/facade/Q.ts b/app/lib/database/facade/Q.ts new file mode 100644 index 00000000000..70892ccd59e --- /dev/null +++ b/app/lib/database/facade/Q.ts @@ -0,0 +1,151 @@ +/** + * Q namespace — clause descriptor objects only. + * No eager Drizzle refs; safe to import without a DB handle. + * + * Mirrors WatermelonDB's surface: comparison operators (eq, gt, like, …) take ONLY the + * right-hand value and return a Comparison; Q.where(column, valueOrComparison) wraps it + * (a raw value is treated as an implicit eq). Operators are never clauses on their own. + */ + +// --------------------------------------------------------------------------- +// Comparison — right-hand side of a where clause +// --------------------------------------------------------------------------- + +export type Operator = 'eq' | 'notEq' | 'gt' | 'gte' | 'lt' | 'lte' | 'like' | 'notLike' | 'oneOf'; + +export interface Comparison { + __comparison: true; + operator: Operator; + value?: unknown; + values?: unknown[]; +} + +function isComparison(value: unknown): value is Comparison { + return typeof value === 'object' && value !== null && (value as Comparison).__comparison === true; +} + +// --------------------------------------------------------------------------- +// Clause descriptor types +// --------------------------------------------------------------------------- + +export interface WhereDescription { + type: 'where'; + column: string; + comparison: Comparison; +} + +export interface AndDescription { + type: 'and'; + clauses: Clause[]; +} + +export interface OrDescription { + type: 'or'; + clauses: Clause[]; +} + +export interface SortBy { + type: 'sortBy'; + column: string; + direction: 'asc' | 'desc'; +} + +export interface Take { + type: 'take'; + count: number; +} + +export interface Skip { + type: 'skip'; + count: number; +} + +export interface OnDescription { + type: 'on'; + table: string; + clause: Clause; +} + +export type Clause = WhereDescription | AndDescription | OrDescription | SortBy | Take | Skip | OnDescription; + +// Type alias re-exported to match WMDB surface +export type Or = OrDescription; + +// --------------------------------------------------------------------------- +// Comparison operators — take only the right-hand value +// --------------------------------------------------------------------------- + +export function eq(value: unknown): Comparison { + return { __comparison: true, operator: 'eq', value }; +} + +export function notEq(value: unknown): Comparison { + return { __comparison: true, operator: 'notEq', value }; +} + +export function gt(value: unknown): Comparison { + return { __comparison: true, operator: 'gt', value }; +} + +export function gte(value: unknown): Comparison { + return { __comparison: true, operator: 'gte', value }; +} + +export function lt(value: unknown): Comparison { + return { __comparison: true, operator: 'lt', value }; +} + +export function lte(value: unknown): Comparison { + return { __comparison: true, operator: 'lte', value }; +} + +export function like(value: string): Comparison { + return { __comparison: true, operator: 'like', value }; +} + +export function notLike(value: string): Comparison { + return { __comparison: true, operator: 'notLike', value }; +} + +export function oneOf(values: unknown[]): Comparison { + return { __comparison: true, operator: 'oneOf', values }; +} + +// --------------------------------------------------------------------------- +// Clause builders +// --------------------------------------------------------------------------- + +/** A raw value is treated as an implicit eq; a Comparison is used as-is. */ +export function where(column: string, valueOrComparison: unknown): WhereDescription { + const comparison = isComparison(valueOrComparison) ? valueOrComparison : eq(valueOrComparison); + return { type: 'where', column, comparison }; +} + +export function and(...clauses: Clause[]): AndDescription { + return { type: 'and', clauses }; +} + +export function or(...clauses: Clause[]): OrDescription { + return { type: 'or', clauses }; +} + +export function sortBy(column: string, direction: 'asc' | 'desc' = 'asc'): SortBy { + return { type: 'sortBy', column, direction }; +} + +/** Sort-direction constants — passed as the 2nd arg to Q.sortBy (WMDB surface). */ +export const asc = 'asc' as const; +export const desc = 'desc' as const; + +export function take(count: number): Take { + return { type: 'take', count }; +} + +export function skip(count: number): Skip { + return { type: 'skip', count }; +} + +/** Used inside the db module only — correlated EXISTS subquery at translate time. */ +export function on(table: string, clause: Clause): OnDescription { + return { type: 'on', table, clause }; +} diff --git a/app/lib/database/facade/Query.ts b/app/lib/database/facade/Query.ts new file mode 100644 index 00000000000..e3d1ce4c738 --- /dev/null +++ b/app/lib/database/facade/Query.ts @@ -0,0 +1,62 @@ +/** + * Query builder — returned by Collection.query(...clauses). + * Terminal methods: fetch / fetchCount / observe / observeWithColumns. + */ + +import type { Observable } from 'rxjs'; + +import type { Model } from './Model'; +import type * as Q from './Q'; + +export interface ICollection { + _fetchAll(clauses: Q.Clause[]): M[]; + _fetchCount(clauses: Q.Clause[]): number; + _observe(clauses: Q.Clause[]): Observable; + _observeWithColumns(clauses: Q.Clause[], cols: string[]): Observable; +} + +export class Query { + private _collection: ICollection; + private _clauses: Q.Clause[]; + + constructor(collection: ICollection, clauses: Q.Clause[]) { + this._collection = collection; + this._clauses = clauses; + } + + /** Returns all matching records. */ + fetch(): Promise { + return Promise.resolve(this._collection._fetchAll(this._clauses)); + } + + /** Returns count of matching records. */ + fetchCount(): Promise { + return Promise.resolve(this._collection._fetchCount(this._clauses)); + } + + /** Observable that emits on every change to the underlying table. */ + observe(): Observable { + return this._collection._observe(this._clauses); + } + + /** + * Observable that emits only when one of the watched columns changes. + * Used by observeWithColumns(cols) call sites (7 sites). + */ + observeWithColumns(cols: string[]): Observable { + return this._collection._observeWithColumns(this._clauses, cols); + } + + /** Extend this query with additional clauses. */ + extend(...clauses: Q.Clause[]): Query { + return new Query(this._collection, [...this._clauses, ...clauses]); + } + + /** WMDB Query is a thenable — `await collection.query(...)` resolves to the records without `.fetch()`. */ + then( + onFulfilled?: ((value: M[]) => TResult1 | PromiseLike) | null, + onRejected?: ((reason: unknown) => TResult2 | PromiseLike) | null + ): Promise { + return this.fetch().then(onFulfilled, onRejected); + } +} diff --git a/app/lib/database/facade/__tests__/facade.test.ts b/app/lib/database/facade/__tests__/facade.test.ts new file mode 100644 index 00000000000..e2c5c5016da --- /dev/null +++ b/app/lib/database/facade/__tests__/facade.test.ts @@ -0,0 +1,362 @@ +/** + * Facade pure-logic tests — L1 (Jest, no native bridge). + * + * Covers the logic that does NOT touch expo-sqlite at runtime: + * - sanitizedRaw / setRawCoerced coercion + id generation + no _status/_changed + * - decorator round-trips (@field, @date, @json, @readonly) matching WMDB semantics + * - Q clause descriptors → Drizzle SQL translation (where/orderBy/limit/offset) + * - WriterQueue serialization (single-writer discipline) + * + * The native I/O surface (Collection fetch, Database.batch, RxJS observe) is exercised + * by the on-device smoke test, not here. + */ + +import { drizzle } from 'drizzle-orm/sqlite-proxy'; + +// observe.ts imports expo-sqlite at module top; Model/decorators pull it in transitively. +jest.mock('expo-sqlite', () => ({ + addDatabaseChangeListener: jest.fn(() => ({ remove: jest.fn() })) +})); + +import { sanitizedRaw, setRawCoerced, tableSchema, randomId, type TableSchema, type RawRecord } from '../schema'; +import { Model, type ICollection } from '../Model'; +import { field, date, json, readonly } from '../decorators'; +import * as Q from '../Q'; +import { translateClauses } from '../translate'; +import { WriterQueue } from '../writer'; +import { subscriptionsTable } from '../../driver/schema/app'; + +// --------------------------------------------------------------------------- +// Shared test schema +// --------------------------------------------------------------------------- + +const testSchema: TableSchema = tableSchema({ + name: 'things', + columns: [ + { name: 'name', type: 'string' }, + { name: 'nick', type: 'string', isOptional: true }, + { name: 'open', type: 'boolean' }, + { name: 'flag', type: 'boolean', isOptional: true }, + { name: 'count', type: 'number' }, + { name: 'score', type: 'number', isOptional: true } + ] +}); + +function makeCollection(schema: TableSchema): ICollection { + return { table: schema.name, schema } as unknown as ICollection; +} + +// --------------------------------------------------------------------------- +// sanitizedRaw / setRawCoerced +// --------------------------------------------------------------------------- + +describe('sanitizedRaw', () => { + it('generates a 16-char lowercase-alphanumeric id when none is provided', () => { + const raw = sanitizedRaw({}, testSchema); + expect(typeof raw.id).toBe('string'); + expect(raw.id as string).toMatch(/^[a-z0-9]{16}$/); + }); + + it('keeps a provided string id', () => { + const raw = sanitizedRaw({ id: 'abc123' }, testSchema); + expect(raw.id).toBe('abc123'); + }); + + it('generates an id when dirtyRaw.id is not a string', () => { + const raw = sanitizedRaw({ id: 42 as unknown as string }, testSchema); + expect(raw.id as string).toMatch(/^[a-z0-9]{16}$/); + }); + + it('never emits _status or _changed (Drizzle has no such columns)', () => { + const raw = sanitizedRaw({ _status: 'created', _changed: 'name', name: 'x' }, testSchema); + expect(raw).not.toHaveProperty('_status'); + expect(raw).not.toHaveProperty('_changed'); + }); + + it('emits exactly id + every schema column and nothing else', () => { + const raw = sanitizedRaw({ extra: 'ignored' }, testSchema); + expect(Object.keys(raw).sort()).toEqual(['count', 'flag', 'id', 'name', 'nick', 'open', 'score'].sort()); + }); + + it('coerces missing required columns to type zero-values', () => { + const raw = sanitizedRaw({}, testSchema); + expect(raw.name).toBe(''); + expect(raw.open).toBe(false); + expect(raw.count).toBe(0); + }); + + it('coerces missing optional columns to null', () => { + const raw = sanitizedRaw({}, testSchema); + expect(raw.nick).toBeNull(); + expect(raw.flag).toBeNull(); + expect(raw.score).toBeNull(); + }); + + it('generates distinct ids across calls', () => { + const ids = new Set(Array.from({ length: 50 }, () => randomId())); + expect(ids.size).toBe(50); + }); +}); + +describe('setRawCoerced', () => { + const raw: RawRecord = {}; + const col = (over: Partial<{ type: 'string' | 'boolean' | 'number'; isOptional: boolean }>) => ({ + name: 'c', + type: 'string' as const, + ...over + }); + + it('string: keeps strings, blanks non-strings (required), nulls non-strings (optional)', () => { + setRawCoerced(raw, 'c', 'hi', col({ type: 'string' })); + expect(raw.c).toBe('hi'); + setRawCoerced(raw, 'c', 5, col({ type: 'string' })); + expect(raw.c).toBe(''); + setRawCoerced(raw, 'c', 5, col({ type: 'string', isOptional: true })); + expect(raw.c).toBeNull(); + }); + + it('boolean: keeps booleans, maps 1/0, else false/null', () => { + setRawCoerced(raw, 'c', true, col({ type: 'boolean' })); + expect(raw.c).toBe(true); + setRawCoerced(raw, 'c', 1, col({ type: 'boolean' })); + expect(raw.c).toBe(true); + setRawCoerced(raw, 'c', 0, col({ type: 'boolean' })); + expect(raw.c).toBe(false); + setRawCoerced(raw, 'c', 'x', col({ type: 'boolean' })); + expect(raw.c).toBe(false); + setRawCoerced(raw, 'c', 'x', col({ type: 'boolean', isOptional: true })); + expect(raw.c).toBeNull(); + }); + + it('number: keeps finite numbers, zeroes/nulls NaN and Infinity', () => { + setRawCoerced(raw, 'c', 7, col({ type: 'number' })); + expect(raw.c).toBe(7); + setRawCoerced(raw, 'c', NaN, col({ type: 'number' })); + expect(raw.c).toBe(0); + setRawCoerced(raw, 'c', Infinity, col({ type: 'number' })); + expect(raw.c).toBe(0); + setRawCoerced(raw, 'c', NaN, col({ type: 'number', isOptional: true })); + expect(raw.c).toBeNull(); + }); +}); + +// --------------------------------------------------------------------------- +// Decorators +// --------------------------------------------------------------------------- + +const passthrough = (v: unknown) => v; + +class Thing extends Model { + @field('name') name!: string; + + @date('ts') ts!: Date | null; + + @json('meta', passthrough) meta!: unknown; +} + +const thingSchema: TableSchema = tableSchema({ + name: 'thing', + columns: [ + { name: 'name', type: 'string' }, + { name: 'ts', type: 'number', isOptional: true }, + { name: 'meta', type: 'string', isOptional: true }, + { name: 'frozen', type: 'string' } + ] +}); + +function newThing(raw: RawRecord = {}): Thing { + return new Thing(makeCollection(thingSchema), sanitizedRaw(raw, thingSchema)); +} + +describe('@field', () => { + it('reads and writes the raw column', () => { + const t = newThing(); + t.name = 'hello'; + expect(t.name).toBe('hello'); + expect(t._raw.name).toBe('hello'); + }); + + it('coerces on write via _setRaw', () => { + const t = newThing(); + (t as unknown as { name: unknown }).name = 123; + expect(t.name).toBe(''); // required string, non-string -> '' + }); +}); + +describe('@date', () => { + it('stores ms on set and returns a Date on get', () => { + const t = newThing(); + const d = new Date('2024-01-02T03:04:05.000Z'); + t.ts = d; + expect(t._raw.ts).toBe(+d); + expect(t.ts).toBeInstanceOf(Date); + expect((t.ts as Date).getTime()).toBe(+d); + }); + + it('returns null when raw is null', () => { + const t = newThing(); + t.ts = null; + expect(t._raw.ts).toBeNull(); + expect(t.ts).toBeNull(); + }); + + it('memoizes the Date instance across repeated gets', () => { + const t = newThing({ ts: 1700000000000 }); + expect(t.ts).toBe(t.ts); + }); +}); + +describe('@json', () => { + it('stringifies on set and parses+sanitizes on get', () => { + const t = newThing(); + t.meta = { a: 1, b: ['x'] }; + expect(typeof t._raw.meta).toBe('string'); + expect(t.meta).toEqual({ a: 1, b: ['x'] }); + }); + + it('writes null when the sanitized value is null/undefined', () => { + const t = newThing(); + t.meta = null; + expect(t._raw.meta).toBeNull(); + }); + + it('returns undefined for empty/invalid raw json', () => { + const t = newThing({ meta: '' }); + expect(t.meta).toBeUndefined(); + t._raw.meta = 'not-json'; + expect(t.meta).toBeUndefined(); + }); +}); + +describe('@readonly', () => { + // Stacked decorator syntax (@readonly @field) can't be expressed in a .ts test — TS types + // property decorators without a descriptor param. Compose the descriptors as babel does at runtime. + it('wraps the underlying setter to throw while keeping the getter', () => { + const base = field('frozen')(Thing.prototype, 'frozen'); + const desc = readonly(Thing.prototype, 'frozen', base); + const obj = new Thing(makeCollection(thingSchema), sanitizedRaw({ frozen: 'locked' }, thingSchema)); + Object.defineProperty(obj, 'frozen', desc); + expect((obj as unknown as { frozen: string }).frozen).toBe('locked'); + expect(() => { + (obj as unknown as { frozen: string }).frozen = 'changed'; + }).toThrow(/@readonly/); + }); +}); + +// --------------------------------------------------------------------------- +// Q -> Drizzle translation +// --------------------------------------------------------------------------- + +describe('translateClauses', () => { + const proxyDb = drizzle(async () => ({ rows: [] })); + + const buildSql = (clauses: Q.Clause[]) => { + const { where, orderBy, limit, offset } = translateClauses(clauses, subscriptionsTable); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + let q: any = proxyDb.select().from(subscriptionsTable); + if (where) q = q.where(where); + if (orderBy.length) q = q.orderBy(...orderBy); + if (limit !== undefined) q = q.limit(limit); + if (offset !== undefined) q = q.offset(offset); + return q.toSQL(); + }; + + it('translates where(eq)', () => { + const { sql, params } = buildSql([Q.where('rid', 'GENERAL')]); + expect(sql).toContain('where "subscriptions"."rid" = ?'); + expect(params).toEqual(['GENERAL']); + }); + + it('translates and() of multiple wheres', () => { + const { sql } = buildSql([Q.and(Q.where('open', true), Q.where('archived', false))]); + expect(sql).toContain('"subscriptions"."open" = ?'); + expect(sql).toContain('"subscriptions"."archived" = ?'); + expect(sql).toContain(' and '); + }); + + it('translates or()', () => { + const { sql } = buildSql([Q.or(Q.where('t', 'c'), Q.where('t', 'p'))]); + expect(sql).toContain(' or '); + }); + + it('translates where(oneOf) -> IN', () => { + const { sql, params } = buildSql([Q.where('rid', Q.oneOf(['a', 'b', 'c']))]); + expect(sql).toContain(' in (?, ?, ?)'); + expect(params).toEqual(['a', 'b', 'c']); + }); + + it('translates where(notEq), where(gt), where(lte), where(like), where(notLike)', () => { + expect(buildSql([Q.where('t', Q.notEq('d'))]).sql).toContain('<>'); + expect(buildSql([Q.where('unread', Q.gt(0))]).sql).toContain('>'); + expect(buildSql([Q.where('unread', Q.lte(5))]).sql).toContain('<='); + expect(buildSql([Q.where('name', Q.like('%x%'))]).sql).toContain('like'); + expect(buildSql([Q.where('name', Q.notLike('%x%'))]).sql).toContain('not '); + }); + + it('lowers a null comparison to IS NULL / IS NOT NULL', () => { + expect(buildSql([Q.where('rid', null)]).sql).toContain('is null'); + expect(buildSql([Q.where('rid', Q.notEq(null))]).sql).toContain('is not null'); + }); + + it('translates sortBy asc/desc into order by', () => { + expect(buildSql([Q.sortBy('room_updated_at', Q.desc)]).sql).toContain('order by "subscriptions"."room_updated_at" desc'); + expect(buildSql([Q.sortBy('name', Q.asc)]).sql).toContain('order by "subscriptions"."name" asc'); + }); + + it('translates take/skip into limit/offset', () => { + const { sql, params } = buildSql([Q.take(10), Q.skip(20)]); + expect(sql).toContain('limit ?'); + expect(sql).toContain('offset ?'); + expect(params).toEqual(expect.arrayContaining([10, 20])); + }); + + it('combines where + order + limit in one query', () => { + const { sql } = buildSql([Q.where('open', true), Q.sortBy('room_updated_at', Q.desc), Q.take(50)]); + expect(sql).toContain('where'); + expect(sql).toContain('order by'); + expect(sql).toContain('limit'); + }); + + it('throws on an unknown column', () => { + expect(() => buildSql([Q.where('not_a_column', 1)])).toThrow(/not found/); + }); +}); + +// --------------------------------------------------------------------------- +// WriterQueue +// --------------------------------------------------------------------------- + +describe('WriterQueue', () => { + it('runs enqueued writers one at a time, in order', async () => { + const queue = new WriterQueue(); + const events: string[] = []; + + const p1 = queue.enqueue(async () => { + events.push('start-1'); + await new Promise(r => setTimeout(r, 20)); + events.push('end-1'); + return 1; + }); + const p2 = queue.enqueue(async () => { + events.push('start-2'); + return 2; + }); + + const [r1, r2] = await Promise.all([p1, p2]); + expect(r1).toBe(1); + expect(r2).toBe(2); + // start-2 must come after end-1 (serialized, not interleaved) + expect(events).toEqual(['start-1', 'end-1', 'start-2']); + }); + + it('keeps the queue alive after a writer rejects', async () => { + const queue = new WriterQueue(); + await expect(queue.enqueue(async () => Promise.reject(new Error('boom')))).rejects.toThrow('boom'); + await expect(queue.enqueue(async () => 'ok')).resolves.toBe('ok'); + }); + + it('propagates the resolved value to the caller', async () => { + const queue = new WriterQueue(); + await expect(queue.enqueue(async () => 'value')).resolves.toBe('value'); + }); +}); diff --git a/app/lib/database/facade/decorators.ts b/app/lib/database/facade/decorators.ts new file mode 100644 index 00000000000..2be2c1ade56 --- /dev/null +++ b/app/lib/database/facade/decorators.ts @@ -0,0 +1,262 @@ +/** + * Decorator implementations matching WMDB semantics verbatim. + * @field, @date, @json, @readonly, @children, @relation + * + * All decorators operate on Model subclasses via _getRaw/_setRaw. + * Using legacy decorator signature (experimentalDecorators: true). + */ + +import type { Observable } from 'rxjs'; + +import { type Model, type ICollection } from './Model'; +import type { Query } from './Query'; +import * as Q from './Q'; +import { observeRow } from './observe'; + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +type PropertyDescriptorLike = { + configurable?: boolean; + enumerable?: boolean; + get?: () => unknown; + set?: (v: unknown) => void; + value?: unknown; + writable?: boolean; +}; + +// Legacy property decorators return a replacement descriptor at runtime (babel applies it), +// but TS only permits a void/any return for property decorators. This alias carries the descriptor +// shape through the implementation while satisfying the decorator-return contract. +// eslint-disable-next-line @typescript-eslint/no-explicit-any +type LegacyDecoratorReturn = any; + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +type AnyModel = Model & Record; + +// --------------------------------------------------------------------------- +// parseJSON — matches WMDB json/index.js exactly +// --------------------------------------------------------------------------- + +function parseJSON(value: unknown): unknown { + if (value === null || value === undefined || value === '') return undefined; + try { + return JSON.parse(value as string); + } catch { + return undefined; + } +} + +// --------------------------------------------------------------------------- +// @field(col) +// --------------------------------------------------------------------------- + +export function field(columnName: string) { + return function (_target: unknown, _key: string, _descriptor?: PropertyDescriptorLike): LegacyDecoratorReturn { + return { + configurable: true, + enumerable: true, + get(this: AnyModel) { + return this.asModel._getRaw(columnName); + }, + set(this: AnyModel, value: unknown) { + this.asModel._setRaw(columnName, value); + } + }; + }; +} + +// --------------------------------------------------------------------------- +// @date(col) +// --------------------------------------------------------------------------- + +export function date(columnName: string) { + return function (_target: unknown, _key: string, _descriptor?: PropertyDescriptorLike): LegacyDecoratorReturn { + return { + configurable: true, + enumerable: true, + get(this: AnyModel): Date | null { + const rawValue = this.asModel._getRaw(columnName); + if (typeof rawValue === 'number') { + const cached = this.asModel._dateCache.get(rawValue); + if (cached) return cached; + const d = new Date(rawValue); + this.asModel._dateCache.set(rawValue, d); + return d; + } + return null; + }, + set(this: AnyModel, value: unknown) { + const date = value as Date | null | number | undefined; + const rawValue = date ? +new Date(date as Date) : null; + if (rawValue && date) { + this.asModel._dateCache.set(rawValue, new Date(date as Date)); + } + this.asModel._setRaw(columnName, rawValue); + } + }; + }; +} + +// --------------------------------------------------------------------------- +// @json(col, sanitizer) +// --------------------------------------------------------------------------- + +export function json( + rawFieldName: string, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + sanitizer: (value: unknown, model: Model) => any +) { + return function (_target: unknown, _key: string, _descriptor?: PropertyDescriptorLike): LegacyDecoratorReturn { + return { + configurable: true, + enumerable: true, + get(this: AnyModel): unknown { + const model = this.asModel; + const rawValue = model._getRaw(rawFieldName); + const parsedValue = parseJSON(rawValue); + const sanitized = sanitizer(parsedValue, model); + return sanitized; + }, + set(this: AnyModel, value: unknown): void { + const model = this.asModel; + const sanitizedValue = sanitizer(value, model); + const stringifiedValue = sanitizedValue != null ? JSON.stringify(sanitizedValue) : null; + model._setRaw(rawFieldName, stringifiedValue); + } + }; + }; +} + +// --------------------------------------------------------------------------- +// @readonly — wraps underlying descriptor's setter to throw +// --------------------------------------------------------------------------- + +export function readonly(_target: unknown, key: string, descriptor: PropertyDescriptorLike): LegacyDecoratorReturn { + if (descriptor.get || descriptor.set) { + return { + ...descriptor, + set() { + throw new Error(`Attempt to set value on @readonly property '${key}'`); + } + }; + } + return { ...descriptor, writable: false }; +} + +// --------------------------------------------------------------------------- +// @children(childTable) +// --------------------------------------------------------------------------- + +export function children(childTable: string) { + return function (_target: unknown, _key: string, _descriptor?: PropertyDescriptorLike): LegacyDecoratorReturn { + return { + configurable: true, + enumerable: true, + get(this: AnyModel): Query { + const model = this.asModel; + const cache = model._childrenQueryCache; + if (cache[childTable]) return cache[childTable] as Query; + + const childCollection = model.collections.get(childTable) as ICollection; + const association = (model.constructor as { associations?: Record }) + .associations?.[childTable]; + if (!association || association.type !== 'has_many') { + throw new Error(`@children decorator used for a table that's not has_many: ${childTable}`); + } + + const query = (childCollection as unknown as { query: (...c: unknown[]) => Query }).query( + Q.where(association.foreignKey, model.id) + ); + cache[childTable] = query; + return query; + }, + set() { + // no-op like WMDB's logError + } + }; + }; +} + +// --------------------------------------------------------------------------- +// Relation +// --------------------------------------------------------------------------- + +export class Relation { + static readonly _wmelonTag = 'relation'; + + private _model: AnyModel; + private _relationTableName: string; + private _columnName: string; + + constructor(model: AnyModel, relationTableName: string, columnName: string) { + this._model = model; + this._relationTableName = relationTableName; + this._columnName = columnName; + } + + get id(): string | null { + return this._model._getRaw(this._columnName) as string | null; + } + + set id(newId: string | null | undefined) { + this._model._setRaw(this._columnName, newId ?? null); + } + + fetch(): Promise { + const { id } = this; + if (id) { + const col = this._model.collections.get(this._relationTableName); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + return (col as any)._db.get(this._relationTableName).find(id); + } + return Promise.resolve(null); + } + + then(onFulfill: (v: T | null) => U, onReject?: (r: unknown) => U): Promise { + return this.fetch().then(onFulfill, onReject); + } + + set(record: T | null | undefined): void { + this.id = record?.id ?? null; + } + + observe(): Observable { + const { _handle } = this._model._collection; + const model = this._model; + const relationTableName = this._relationTableName; + const columnName = this._columnName; + return observeRow(_handle, relationTableName, () => { + const id = model._getRaw(columnName) as string | null; + if (!id) return null; + const col = model.collections.get(relationTableName); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const rows = (col as any)._fetchSync({ id }); + return rows.length > 0 ? rows[0] : null; + }) as Observable; + } +} + +// --------------------------------------------------------------------------- +// @relation(table, idColumn) +// --------------------------------------------------------------------------- + +export function relation(table: string, idColumn: string) { + return function (_target: unknown, _key: string, _descriptor?: PropertyDescriptorLike): LegacyDecoratorReturn { + const cacheKey = `_rel_${table}_${idColumn}`; + return { + configurable: true, + enumerable: true, + get(this: AnyModel): Relation { + if (!this[cacheKey]) { + this[cacheKey] = new Relation(this.asModel, table, idColumn); + } + return this[cacheKey] as Relation; + }, + set() { + // Relation is read-only on the model (set via .set(record) on the Relation instance) + } + }; + }; +} diff --git a/app/lib/database/facade/index.ts b/app/lib/database/facade/index.ts new file mode 100644 index 00000000000..1ae66a6af0b --- /dev/null +++ b/app/lib/database/facade/index.ts @@ -0,0 +1,25 @@ +/** + * Public surface of the facade. + * + * Re-exports consumed by the ~80 call sites that currently import from @nozbe/watermelondb. + * After cutover (NATIVE-1282) the WMDB package is removed; imports point here instead. + */ + +// Core types — Model is exported as a value: model classes inside the db module do `extends Model`. +export { Model } from './Model'; +export { Database } from './Database'; +export type { Collection } from './Collection'; +export type { Query } from './Query'; +export { Relation } from './decorators'; + +// Q namespace +export * as Q from './Q'; +// Also export individual Q types for `type Q.WhereDescription` etc. +export type { WhereDescription, SortBy, Clause, Skip, Take, Or } from './Q'; + +// sanitizedRaw + schema builders +export { sanitizedRaw, appSchema, tableSchema } from './schema'; +export type { TableSchema, AppSchema, ColumnSchema, RawRecord } from './schema'; + +// Decorators +export { field, date, json, readonly, children, relation } from './decorators'; diff --git a/app/lib/database/facade/observe.ts b/app/lib/database/facade/observe.ts new file mode 100644 index 00000000000..bc6123759b6 --- /dev/null +++ b/app/lib/database/facade/observe.ts @@ -0,0 +1,194 @@ +/** + * RxJS Observable bridge over expo-sqlite's addDatabaseChangeListener. + * + * NOT the React hooks in driver/observe.ts — this produces real RxJS Observables + * for the 26 observe()/observeWithColumns() sites that store subscriptions in useRef. + * + * Per-table discipline: + * - single addDatabaseChangeListener per subscription + * - filter by databaseFilePath + tableName + * - ~16ms debounce to coalesce per-row events from large transactions + * - structural-share: reuse unchanged row references so React.memo bails out + */ + +import { Observable } from 'rxjs'; +import { addDatabaseChangeListener } from 'expo-sqlite'; + +import type { DbHandle } from '../driver/connection'; + +// --------------------------------------------------------------------------- +// Structural sharing helpers +// --------------------------------------------------------------------------- + +/** Row-like emitted by the observables: a Model (has `id` + `_raw`) or a plain row. */ +interface HasId { + id: string; + _raw?: Record; +} + +type RawRow = Record; + +/** The underlying row data used for equality — Model._raw when present, else the value itself. */ +function rowData(x: HasId): RawRow { + return x._raw ?? (x as unknown as RawRow); +} + +/** Replace each entry in next with the previous reference when content is identical. */ +function structuralShare(prev: Map, next: T[]): T[] { + const result: T[] = new Array(next.length); + for (let i = 0; i < next.length; i++) { + const row = next[i]; + const old = prev.get(row.id); + result[i] = old !== undefined && shallowEqual(rowData(old), rowData(row)) ? old : row; + } + return result; +} + +function shallowEqual(a: RawRow, b: RawRow): boolean { + const keysA = Object.keys(a); + if (keysA.length !== Object.keys(b).length) return false; + for (const k of keysA) { + if (a[k] !== b[k]) return false; + } + return true; +} + +// --------------------------------------------------------------------------- +// Table Observable +// --------------------------------------------------------------------------- + +/** + * Produces an Observable that emits a new array whenever the given table changes. + * Re-runs fetchFn and structurally shares unchanged row references. + */ +export function observeTable( + handle: DbHandle, + tableName: string, + fetchFn: () => T[], + debounceMs = 16 +): Observable { + return new Observable(subscriber => { + const prevMap = new Map(); + + const emit = () => { + if (subscriber.closed) return; + const fresh = fetchFn(); + const shared = structuralShare(prevMap, fresh); + prevMap.clear(); + for (const row of shared) { + prevMap.set(row.id, row); + } + subscriber.next(shared); + }; + + // Initial emit + emit(); + + let timer: ReturnType | null = null; + const sub = addDatabaseChangeListener(event => { + if (!event.databaseFilePath.endsWith(`/${handle.dbName}`)) return; + if (event.tableName !== tableName) return; + if (timer !== null) clearTimeout(timer); + timer = setTimeout(emit, debounceMs); + }); + + return () => { + sub.remove(); + if (timer !== null) clearTimeout(timer); + }; + }); +} + +/** + * Produces an Observable that emits whenever a specific row (by id) changes. + * Re-runs fetchFn on any change to the table (debounced); the fetchFn resolves the specific row by id. + */ +export function observeRow(handle: DbHandle, tableName: string, fetchFn: () => T | null, debounceMs = 16): Observable { + return new Observable(subscriber => { + const emit = () => { + if (subscriber.closed) return; + const row = fetchFn(); + if (row !== null) subscriber.next(row); + }; + + // Initial emit + emit(); + + let timer: ReturnType | null = null; + const sub = addDatabaseChangeListener(event => { + if (!event.databaseFilePath.endsWith(`/${handle.dbName}`)) return; + if (event.tableName !== tableName) return; + if (timer !== null) clearTimeout(timer); + timer = setTimeout(emit, debounceMs); + }); + + return () => { + sub.remove(); + if (timer !== null) clearTimeout(timer); + }; + }); +} + +/** + * Like observeTable, but only re-emits when one of the watched columns changes. + * Used by observeWithColumns. + */ +export function observeTableWithColumns( + handle: DbHandle, + tableName: string, + columns: string[], + fetchFn: () => T[], + debounceMs = 16 +): Observable { + const colSet = new Set(columns); + return new Observable(subscriber => { + const prevMap = new Map(); + let lastRows: T[] = []; + + const emit = (force = false) => { + if (subscriber.closed) return; + const fresh = fetchFn(); + const shared = structuralShare(prevMap, fresh); + + // Diff on watched columns only + if (!force && sameByColumns(lastRows, shared, colSet)) return; + + prevMap.clear(); + for (const row of shared) { + prevMap.set(row.id, row); + } + lastRows = shared; + subscriber.next(shared); + }; + + // Initial emit (force = true so it always fires once) + emit(true); + + let timer: ReturnType | null = null; + const sub = addDatabaseChangeListener(event => { + if (!event.databaseFilePath.endsWith(`/${handle.dbName}`)) return; + if (event.tableName !== tableName) return; + if (timer !== null) clearTimeout(timer); + timer = setTimeout(() => emit(false), debounceMs); + }); + + return () => { + sub.remove(); + if (timer !== null) clearTimeout(timer); + }; + }); +} + +function sameByColumns(prev: T[], next: T[], cols: Set): boolean { + if (prev.length !== next.length) return false; + const prevById = new Map(prev.map(r => [r.id, rowData(r)])); + for (const row of next) { + const a = prevById.get(row.id); + if (!a) return false; + const b = rowData(row); + for (const col of cols) { + if (a[col] !== b[col]) return false; + } + } + return true; +} diff --git a/app/lib/database/facade/schema.ts b/app/lib/database/facade/schema.ts new file mode 100644 index 00000000000..6859b97f7ef --- /dev/null +++ b/app/lib/database/facade/schema.ts @@ -0,0 +1,125 @@ +/** + * Facade re-implementations of appSchema/tableSchema/sanitizedRaw. + * + * Consumes the existing WMDB-shaped schema/app.js and schema/servers.js definitions. + * sanitizedRaw MUST NOT emit _status/_changed — Drizzle has no such columns. + */ + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +export interface ColumnSchema { + name: string; + type: 'string' | 'boolean' | 'number'; + isOptional?: boolean; + isIndexed?: boolean; +} + +export interface TableSchema { + name: string; + columns: ColumnSchema[]; + columnArray: ColumnSchema[]; + /** Keyed by column name for O(1) lookup */ + columnsByName: Record; +} + +export interface AppSchema { + version: number; + tables: Record; +} + +export type RawRecord = Record; + +// --------------------------------------------------------------------------- +// Factories +// --------------------------------------------------------------------------- + +export function tableSchema(input: { name: string; columns: ColumnSchema[] }): TableSchema { + const columnArray = input.columns; + const columnsByName: Record = {}; + for (const col of columnArray) { + columnsByName[col.name] = col; + } + return { name: input.name, columns: columnArray, columnArray, columnsByName }; +} + +export function appSchema(input: { version: number; tables: TableSchema[] }): AppSchema { + const tables: Record = {}; + for (const t of input.tables) { + tables[t.name] = t; + } + return { version: input.version, tables }; +} + +// --------------------------------------------------------------------------- +// Random ID — WMDB style: lowercase alphanumeric, 16 chars +// --------------------------------------------------------------------------- + +const CHARS = 'abcdefghijklmnopqrstuvwxyz0123456789'; + +export function randomId(): string { + let id = ''; + for (let i = 0; i < 16; i++) { + id += CHARS[Math.floor(Math.random() * CHARS.length)]; + } + return id; +} + +// --------------------------------------------------------------------------- +// _setRaw coercion — matches WMDB RawRecord/index.js verbatim +// --------------------------------------------------------------------------- + +function isValidNumber(value: unknown): value is number { + return typeof value === 'number' && !Number.isNaN(value) && value !== Infinity && value !== -Infinity; +} + +export function setRawCoerced(raw: RawRecord, key: string, value: unknown, col: ColumnSchema): void { + const { type, isOptional } = col; + if (type === 'string') { + if (typeof value === 'string') { + raw[key] = value; + } else { + raw[key] = isOptional ? null : ''; + } + } else if (type === 'boolean') { + if (typeof value === 'boolean') { + raw[key] = value; + } else if (value === 1 || value === 0) { + raw[key] = Boolean(value); + } else { + raw[key] = isOptional ? null : false; + } + } else if (isValidNumber(value)) { + // number column, valid value + raw[key] = value; + } else { + // number column, invalid value → default + raw[key] = isOptional ? null : 0; + } +} + +// --------------------------------------------------------------------------- +// sanitizedRaw +// --------------------------------------------------------------------------- + +/** + * Coerces dirtyRaw into a Drizzle-insertable record. + * Deliberately omits _status/_changed (no such Drizzle columns). + * Generates a random id when dirtyRaw.id is not a string. + */ +export function sanitizedRaw(dirtyRaw: Record, schema: TableSchema): RawRecord { + const raw: RawRecord = {}; + + raw.id = typeof dirtyRaw.id === 'string' ? dirtyRaw.id : randomId(); + + const columns = schema.columnArray; + for (let i = 0, len = columns.length; i < len; i++) { + const col = columns[i]; + const key = col.name; + const value = Object.prototype.hasOwnProperty.call(dirtyRaw, key) ? dirtyRaw[key] : null; + setRawCoerced(raw, key, value, col); + } + + return raw; +} diff --git a/app/lib/database/facade/translate.ts b/app/lib/database/facade/translate.ts new file mode 100644 index 00000000000..efc471de63a --- /dev/null +++ b/app/lib/database/facade/translate.ts @@ -0,0 +1,107 @@ +/** + * Lowers Q clause descriptors to Drizzle SQL expressions. + * Operates against a Drizzle table's column map (Record). + */ + +import { and, or, eq, ne, gt, gte, lt, lte, like, inArray, not, isNull, isNotNull, asc, desc, type SQL } from 'drizzle-orm'; +import type { SQLiteTable } from 'drizzle-orm/sqlite-core'; +import { getTableColumns } from 'drizzle-orm'; + +import type * as Q from './Q'; + +export interface TranslatedQuery { + where: SQL | undefined; + orderBy: SQL[]; + limit: number | undefined; + offset: number | undefined; +} + +type ColumnMap = ReturnType; +type Column = ColumnMap[string]; + +function resolveColumn(columns: ColumnMap, name: string): Column { + const col = columns[name]; + if (!col) throw new Error(`Column '${name}' not found in table`); + return col; +} + +function translateComparison(col: Column, comparison: Q.Comparison): SQL | undefined { + const { operator, value, values } = comparison; + switch (operator) { + // SQL `= NULL` / `<> NULL` are never true; WMDB lowers null comparisons to IS [NOT] NULL. + case 'eq': + // eslint-disable-next-line @typescript-eslint/no-explicit-any + return value === null ? isNull(col) : eq(col, value as any); + case 'notEq': + // eslint-disable-next-line @typescript-eslint/no-explicit-any + return value === null ? isNotNull(col) : ne(col, value as any); + case 'gt': + // eslint-disable-next-line @typescript-eslint/no-explicit-any + return gt(col, value as any); + case 'gte': + // eslint-disable-next-line @typescript-eslint/no-explicit-any + return gte(col, value as any); + case 'lt': + // eslint-disable-next-line @typescript-eslint/no-explicit-any + return lt(col, value as any); + case 'lte': + // eslint-disable-next-line @typescript-eslint/no-explicit-any + return lte(col, value as any); + case 'like': + return like(col, value as string); + case 'notLike': + return not(like(col, value as string)); + case 'oneOf': + // eslint-disable-next-line @typescript-eslint/no-explicit-any + return inArray(col, values as any[]); + } +} + +function translateWhere(clause: Q.Clause, columns: ColumnMap): SQL | undefined { + switch (clause.type) { + case 'where': + return translateComparison(resolveColumn(columns, clause.column), clause.comparison); + case 'and': { + const conditions = clause.clauses.map(c => translateWhere(c, columns)).filter(Boolean); + return and(...(conditions as SQL[])) ?? undefined; + } + case 'or': { + const conditions = clause.clauses.map(c => translateWhere(c, columns)).filter(Boolean); + return or(...(conditions as SQL[])) ?? undefined; + } + // sortBy/take/skip are not where clauses; they are handled in translateClauses + case 'sortBy': + case 'take': + case 'skip': + return undefined; + case 'on': + throw new Error('Q.on is not supported by the facade translator; build the correlated subquery at the call site'); + } +} + +/** Translates a clause list into a structured query descriptor for Drizzle. */ +export function translateClauses(clauses: Q.Clause[], table: SQLiteTable): TranslatedQuery { + const columns = getTableColumns(table); + const whereParts: (SQL | undefined)[] = []; + const orderBy: SQL[] = []; + let limit: number | undefined; + let offset: number | undefined; + + for (const clause of clauses) { + if (clause.type === 'sortBy') { + const col = resolveColumn(columns, clause.column); + orderBy.push(clause.direction === 'desc' ? desc(col) : asc(col)); + } else if (clause.type === 'take') { + limit = clause.count; + } else if (clause.type === 'skip') { + offset = clause.count; + } else { + const w = translateWhere(clause, columns); + if (w) whereParts.push(w); + } + } + + const where = whereParts.length > 0 ? and(...(whereParts as SQL[])) : undefined; + + return { where, orderBy, limit, offset }; +} diff --git a/app/lib/database/facade/writer.ts b/app/lib/database/facade/writer.ts new file mode 100644 index 00000000000..2b0d3b87602 --- /dev/null +++ b/app/lib/database/facade/writer.ts @@ -0,0 +1,34 @@ +/** + * Serialized write queue — promise-chain mutex. + * Ensures only one writer runs at a time, matching WMDB single-writer semantics. + */ + +export class WriterQueue { + private _tail: Promise = Promise.resolve(); + + /** Enqueue a writer fn. Returns the result of fn. */ + enqueue(fn: () => Promise): Promise { + let resolve!: (value: T | PromiseLike) => void; + let reject!: (reason?: unknown) => void; + + const result = new Promise((res, rej) => { + resolve = res; + reject = rej; + }); + + this._tail = this._tail + .then(async () => { + try { + resolve(await fn()); + } catch (e) { + reject(e); + } + }) + .then( + () => undefined, + () => undefined + ); + + return result; + } +} diff --git a/app/lib/database/index.ts b/app/lib/database/index.ts index b9a26842379..c17275e00d4 100644 --- a/app/lib/database/index.ts +++ b/app/lib/database/index.ts @@ -1,105 +1,67 @@ -import { Database } from '@nozbe/watermelondb'; -import SQLiteAdapter from '@nozbe/watermelondb/adapters/sqlite'; -import logger from '@nozbe/watermelondb/utils/common/logger'; - -import { appGroupPath } from '../methods/appGroup'; -import Subscription from './model/Subscription'; -import Room from './model/Room'; -import Message from './model/Message'; -import Thread from './model/Thread'; -import ThreadMessage from './model/ThreadMessage'; -import CustomEmoji from './model/CustomEmoji'; -import FrequentlyUsedEmoji from './model/FrequentlyUsedEmoji'; -import Upload from './model/Upload'; -import Setting from './model/Setting'; -import Role from './model/Role'; -import Permission from './model/Permission'; -import SlashCommand from './model/SlashCommand'; -import User from './model/User'; -import LoggedUser from './model/servers/User'; -import Server from './model/servers/Server'; -import ServersHistory from './model/ServersHistory'; +import { Database } from './facade'; +import { openServersDb, openServerDb } from './driver/connection'; +import { installNativeKeychainShim } from './driver/keyStore'; +import { appTableMap, appModelMap, serversTableMap, serversModelMap } from './tableMaps'; import serversSchema from './schema/servers'; import appSchema from './schema/app'; -import migrations from './model/migrations'; -import serversMigrations from './model/servers/migrations'; import { type TAppDatabase, type TServerDatabase } from './interfaces'; -if (__DEV__) { - console.log(appGroupPath); -} - -const getDatabasePath = (name: string) => `${appGroupPath}${name}.db`; - -export const getDatabase = (database = ''): Database => { - const path = database.replace(/(^\w+:|^)\/\//, '').replace(/\//g, '.'); - const dbName = getDatabasePath(path); - - const adapter = new SQLiteAdapter({ - dbName, - schema: appSchema, - migrations, - jsi: true, - // @ts-expect-error - experimentalUnsafeNativeReuse: true - }); - - return new Database({ - adapter, - modelClasses: [ - Subscription, - Room, - Message, - Thread, - ThreadMessage, - CustomEmoji, - FrequentlyUsedEmoji, - Upload, - Setting, - Role, - Permission, - SlashCommand, - User - ] - }); +/** + * Opens (or returns the cached handle for) the per-server app database and wraps it + * in a fresh facade Database. Used for one-off resets where the target server is not + * necessarily the active one (see logout). + */ +export const getDatabase = async (database = ''): Promise => { + const handle = await openServerDb(database); + return new Database(handle, appSchema, appTableMap, appModelMap) as unknown as TAppDatabase; }; interface IDatabases { - serversDB: TServerDatabase; + serversDB?: TServerDatabase; activeDB?: TAppDatabase; } class DB { - databases: IDatabases = { - serversDB: new Database({ - adapter: new SQLiteAdapter({ - dbName: getDatabasePath('default'), - schema: serversSchema, - migrations: serversMigrations, - jsi: true, - // @ts-expect-error - experimentalUnsafeNativeReuse: true - }), - modelClasses: [Server, LoggedUser, ServersHistory] - }) as TServerDatabase - }; + databases: IDatabases = {}; get active(): TAppDatabase { - return this.databases.activeDB!; + if (!this.databases.activeDB) { + throw new Error('Active database accessed before setActiveDB() resolved'); + } + return this.databases.activeDB; } - get servers() { + get servers(): TServerDatabase { + if (!this.databases.serversDB) { + throw new Error('Servers database accessed before initServers() resolved'); + } return this.databases.serversDB; } - setActiveDB(database: string) { - this.databases.activeDB = getDatabase(database) as TAppDatabase; - } + /** + * Installs the native key shim and opens the global servers database. + * Must resolve before any consumer reads `database.servers`. + * Arrow field so `yield call(database.initServers)` keeps its `this`. + */ + initServers = async (): Promise => { + if (this.databases.serversDB) { + return; + } + installNativeKeychainShim(); + const handle = await openServersDb(); + this.databases.serversDB = new Database( + handle, + serversSchema, + serversTableMap, + serversModelMap + ) as unknown as TServerDatabase; + }; + + setActiveDB = async (database = ''): Promise => { + const handle = await openServerDb(database); + this.databases.activeDB = new Database(handle, appSchema, appTableMap, appModelMap) as unknown as TAppDatabase; + }; } const db = new DB(); export default db; - -if (!__DEV__) { - logger.silence(); -} diff --git a/app/lib/database/interfaces.ts b/app/lib/database/interfaces.ts index 3308a0bb9ca..cc9a84c3cc2 100644 --- a/app/lib/database/interfaces.ts +++ b/app/lib/database/interfaces.ts @@ -1,5 +1,4 @@ -import { type Database, type Collection } from '@nozbe/watermelondb'; - +import { type Database, type Collection } from './facade'; import type * as models from './model'; import type * as definitions from '../../definitions'; diff --git a/app/lib/database/migration/__tests__/legacyReader.android.test.ts b/app/lib/database/migration/__tests__/legacyReader.android.test.ts new file mode 100644 index 00000000000..eb0c522a740 --- /dev/null +++ b/app/lib/database/migration/__tests__/legacyReader.android.test.ts @@ -0,0 +1,43 @@ +/** + * Android legacy-file addressing regression guard. + * + * WMDB on Android landed plaintext files at the app-data ROOT with a DOUBLE `.db.db` suffix + * (WMDatabase.createSQLiteDatabase appends a second `.db` to RC's already-`.db`-terminated name + * and strips the `/databases` segment). Addressing them with iOS naming (single `.db`, `/databases` + * dir) makes the detect phase find nothing and the whole migration silently no-op on Android. + */ + +jest.mock('react-native', () => ({ + Platform: { OS: 'android' } +})); + +jest.mock('expo-sqlite', () => ({ + openDatabaseAsync: jest.fn() +})); + +jest.mock('expo-file-system', () => ({ + Paths: { + appleSharedContainers: {}, + // app files dir is `/files`; the literal is inlined (not a top-level const) + // because the factory runs during the hoisted require, before any const initializes. + document: { uri: 'file:///data/user/0/chat.rocket.android/files/' } + } +})); + +import { LEGACY_SERVERS_DB_NAME, deriveLegacyServerDbName, resolveLegacyDbDirectory } from '../legacyReader'; + +describe('legacyReader Android addressing', () => { + it('names the global DB with a double .db.db suffix', () => { + expect(LEGACY_SERVERS_DB_NAME).toBe('default.db.db'); + }); + + it('derives per-server names with a double .db.db suffix', () => { + expect(deriveLegacyServerDbName('https://open.rocket.chat')).toBe('open.rocket.chat.db.db'); + expect(deriveLegacyServerDbName('https://open.rocket.chat/')).toBe('open.rocket.chat.db.db'); + }); + + it('resolves the legacy directory to the app-data root, not the databases subdir', () => { + // files dir parent is the data root; WMDB stripped `/databases`, so files live there directly + expect(resolveLegacyDbDirectory()).toBe('file:///data/user/0/chat.rocket.android'); + }); +}); diff --git a/app/lib/database/migration/__tests__/migration.test.ts b/app/lib/database/migration/__tests__/migration.test.ts new file mode 100644 index 00000000000..0e79115fb8c --- /dev/null +++ b/app/lib/database/migration/__tests__/migration.test.ts @@ -0,0 +1,571 @@ +/** + * Migration tests — Jest, fully mocked (no real sqlite, MMKV, or filesystem). + * + * Covers: + * - Fast-path skip when done flag is set + * - detect with no legacy files → skipped + * - Full port path: seeded fake legacy rows assert correct new-DB writes + * - status IN (1,2) filter for pending messages + * - Non-empty-draft filter for subscriptions and threads + * - File-exists filter for uploads + * - Drafts port via INSERT OR IGNORE + UPDATE (never REPLACE) so server-synced columns survive + * - Crash-resume: interrupt after porting_servers, re-run resumes at porting_active + * - Idempotency: legacy files gone after wipe → second run skips + * - Wiping unlinks each legacy file + * + * Shared mutable state referenced inside jest.mock() factories MUST be `mock`-prefixed + * (babel-jest's out-of-scope guard). New vs legacy DB opens are discriminated by directory: + * the legacy reader opens at LEGACY_DIR ('/fake/legacy'); the new driver opens in the + * '/fake/legacy/SQLite' subdirectory. Keying by dbName alone collides (both servers DBs + * are 'default.db'), so the mock decides by `dir`. + */ + +const mockLegacyDir = '/fake/legacy'; + +// --------------------------------------------------------------------------- +// userPreferences mock — in-memory store backing state.ts (severs the heavy +// helpers → Toast → react-native-easy-toast import chain that the real module pulls in) +// --------------------------------------------------------------------------- + +const mockMmkvStore = new Map(); + +jest.mock('../../../methods/userPreferences', () => ({ + __esModule: true, + default: { + getBool: (k: string) => { + const v = mockMmkvStore.get(k); + return typeof v === 'boolean' ? v : null; + }, + setBool: (k: string, v: boolean) => mockMmkvStore.set(k, v), + getMap: (k: string) => { + const v = mockMmkvStore.get(k); + return typeof v === 'string' ? JSON.parse(v) : null; + }, + setMap: (k: string, v: object) => mockMmkvStore.set(k, JSON.stringify(v)), + getString: (k: string) => { + const v = mockMmkvStore.get(k); + return typeof v === 'string' ? v : null; + }, + setString: (k: string, v: string) => mockMmkvStore.set(k, v), + removeItem: (k: string) => mockMmkvStore.delete(k) + } +})); + +// connection.ts and legacyReader.ts read Platform.OS directly +jest.mock('react-native', () => ({ + Platform: { OS: 'ios' } +})); + +// --------------------------------------------------------------------------- +// expo-sqlite mock +// --------------------------------------------------------------------------- + +// Per-dbName row stores for legacy DBs; new DB write logs keyed by dbName +const mockLegacyRows: Record> = {}; +const mockNewDbWrites: Record = {}; +const mockDeletedDbs: string[] = []; +// Files that secureDelete's sidecar pass sees as present, and the URIs it deletes +const mockExistingFiles = new Set(); +const mockDeletedFiles: string[] = []; +// New-DB sqlite mocks persist across re-opens of the same name within a test +const mockNewSqliteMocks: Record> = {}; + +// Real column names of the new (drizzle) tables exercised by the port — used to answer +// PRAGMA table_info so insertRows can drop legacy-only columns (WMDB's _status/_changed, drift). +// Deliberately excludes _status/_changed so the drift-stripping test has teeth. +const mockNewDbColumns: Record = { + users: [ + 'id', + 'token', + 'username', + 'name', + 'language', + 'status', + 'statusText', + 'roles', + 'login_email_password', + 'show_message_in_main_thread', + 'avatar_etag', + 'is_from_webview', + 'enable_message_parser_early_adoption', + 'nickname', + 'bio', + 'require_password_change' + ], + servers_history: ['id', 'url', 'username', 'updated_at', 'icon_url'], + messages: ['id', 'msg', 't', 'rid', 'ts', 'u', 'status', 'attachments', 'tmid', 'content'], + uploads: ['id', 'path', 'rid', 'name', 'tmid', 'description', 'size', 'type', 'store', 'progress', 'error'], + frequently_used_emojis: ['id', 'content', 'extension', 'is_custom', 'count'] +}; + +function mockMakeNewSqlite(dbName: string) { + if (!mockNewDbWrites[dbName]) mockNewDbWrites[dbName] = []; + return { + runAsync: jest.fn(async (sql: string, args?: unknown[]) => { + mockNewDbWrites[dbName].push({ sql, args: args ?? [] }); + }), + execAsync: jest.fn(async () => {}), + getFirstAsync: jest.fn(async () => ({ count: 0 })), + getAllAsync: jest.fn(async (sql: string) => { + const tbl = sql.match(/PRAGMA\s+table_info\((\w+)\)/i)?.[1]; + if (tbl) return (mockNewDbColumns[tbl] ?? []).map(name => ({ name })); + return []; + }), + closeAsync: jest.fn(async () => {}) + }; +} + +function mockMakeLegacySqlite(dbName: string) { + return { + runAsync: jest.fn(async () => {}), + execAsync: jest.fn(async () => {}), + getFirstAsync: jest.fn(async () => ({ count: 0 })), + getAllAsync: jest.fn(async (sql: string) => { + const tbl = sql.match(/FROM\s+(\w+)/i)?.[1]; + if (!tbl) return []; + const all = (mockLegacyRows[dbName]?.[tbl] ?? []) as Record[]; + if (sql.includes('status IN (1, 2)')) { + return all.filter(r => r.status === 1 || r.status === 2); + } + if (sql.includes("draft_message IS NOT NULL AND draft_message != ''")) { + return all.filter(r => r.draft_message && r.draft_message !== ''); + } + if (sql.includes('SELECT id FROM servers')) { + return all.map(r => ({ id: r.id })); + } + return all; + }), + closeAsync: jest.fn(async () => {}) + }; +} + +jest.mock('expo-sqlite', () => ({ + openDatabaseAsync: jest.fn(async (dbName: string, _opts?: unknown, dir?: string) => { + // Legacy reader opens at the container root; the new driver opens in the SQLite subdir. + if (dir === mockLegacyDir) { + return mockMakeLegacySqlite(dbName); + } + if (!mockNewSqliteMocks[dbName]) { + mockNewSqliteMocks[dbName] = mockMakeNewSqlite(dbName); + } + return mockNewSqliteMocks[dbName]; + }), + deleteDatabaseAsync: jest.fn(async (dbName: string) => { + mockDeletedDbs.push(dbName); + }) +})); + +// --------------------------------------------------------------------------- +// expo-file-system mock +// --------------------------------------------------------------------------- + +jest.mock('expo-file-system', () => ({ + Paths: { + appleSharedContainers: { 'group.ios.chat.rocket': { uri: '/fake/legacy/' } }, + document: { uri: '/fake/files/' } + }, + Directory: class { + uri: string; + exists = false; + constructor(...parts: string[]) { + this.uri = parts.join('/').replace(/\/+/g, '/'); + } + create() {} + }, + File: class { + uri: string; + constructor(...parts: string[]) { + this.uri = parts.join('/').replace(/\/+/g, '/'); + } + get exists() { + return mockExistingFiles.has(this.uri); + } + delete() { + mockDeletedFiles.push(this.uri); + mockExistingFiles.delete(this.uri); + } + } +})); + +// --------------------------------------------------------------------------- +// drizzle-orm / migrator / keyService mocks (pulled in transitively by connection.ts) +// --------------------------------------------------------------------------- + +jest.mock('drizzle-orm/expo-sqlite', () => ({ drizzle: jest.fn(() => ({})) })); +jest.mock('drizzle-orm/expo-sqlite/migrator', () => ({ migrate: jest.fn(async () => {}) })); + +jest.mock('../../driver/keyService', () => ({ + getOrCreateDatabaseKey: jest.fn(async () => 'a'.repeat(64)), + getOrCreateDatabaseSalt: jest.fn(async () => 'b'.repeat(32)), + deleteDatabaseKey: jest.fn(async () => {}) +})); + +// --------------------------------------------------------------------------- +// Imports (after mocks are set up) +// --------------------------------------------------------------------------- + +import { isMigrationDone, readState, startPortingActive, _setNowMs, MIGRATION_DONE_KEY, MIGRATION_KEY } from '../state'; +import { _setLegacyDir, _setFileExists } from '../legacyReader'; +import { runMigrationIfNeeded } from '../orchestrator'; +import { _clearRegistry } from '../../driver/connection'; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +function clearAll() { + mockMmkvStore.clear(); + for (const k of Object.keys(mockLegacyRows)) delete mockLegacyRows[k]; + for (const k of Object.keys(mockNewDbWrites)) delete mockNewDbWrites[k]; + for (const k of Object.keys(mockNewSqliteMocks)) delete mockNewSqliteMocks[k]; + mockDeletedDbs.length = 0; + mockExistingFiles.clear(); + mockDeletedFiles.length = 0; + _clearRegistry(); +} + +function seedLegacyDb(dbName: string, table: string, rows: Record[]) { + if (!mockLegacyRows[dbName]) mockLegacyRows[dbName] = {}; + mockLegacyRows[dbName][table] = rows; +} + +const fakeNow = 1_000_000; + +beforeEach(() => { + clearAll(); + _setLegacyDir(mockLegacyDir); + _setFileExists(() => false); + _setNowMs(() => fakeNow); +}); + +// --------------------------------------------------------------------------- +// Fast-path skip +// --------------------------------------------------------------------------- + +describe('fast-path: already done', () => { + it('returns immediately when the done flag is set, without touching legacy files', async () => { + mockMmkvStore.set(MIGRATION_DONE_KEY, true); + await runMigrationIfNeeded(); + // eslint-disable-next-line @typescript-eslint/no-var-requires + const { openDatabaseAsync } = require('expo-sqlite'); + expect(openDatabaseAsync).not.toHaveBeenCalled(); + expect(isMigrationDone()).toBe(true); + }); + + it('returns immediately when the state JSON phase is done', async () => { + mockMmkvStore.set(MIGRATION_KEY, JSON.stringify({ schema: 1, phase: 'done', servers: {}, startedAt: 1, updatedAt: 1 })); + await runMigrationIfNeeded(); + // eslint-disable-next-line @typescript-eslint/no-var-requires + const { openDatabaseAsync } = require('expo-sqlite'); + expect(openDatabaseAsync).not.toHaveBeenCalled(); + }); +}); + +// --------------------------------------------------------------------------- +// Fresh install / no legacy files +// --------------------------------------------------------------------------- + +describe('detect: no legacy files', () => { + it('marks done with phase skipped when default.db does not exist', async () => { + await runMigrationIfNeeded(); + expect(isMigrationDone()).toBe(true); + expect(readState()?.phase).toBe('skipped'); + }); +}); + +// --------------------------------------------------------------------------- +// Full port path +// --------------------------------------------------------------------------- + +describe('full migration', () => { + const SERVER_DB = 'open.rocket.chat.db'; + + beforeEach(() => { + _setFileExists(path => path.includes('default.db') || path.includes(SERVER_DB) || path.includes('/uploads/file1')); + + seedLegacyDb('default.db', 'users', [{ id: 'user1', username: 'alice', token: 'tok1', name: 'Alice' }]); + seedLegacyDb('default.db', 'servers', [ + { id: 'https://open.rocket.chat', auto_lock: 1, auto_lock_time: 300, last_local_authenticated_session: 9999, biometry: 0 } + ]); + seedLegacyDb('default.db', 'servers_history', [ + { id: 'h1', url: 'https://open.rocket.chat', username: 'alice', updated_at: 1000, icon_url: null } + ]); + + seedLegacyDb(SERVER_DB, 'messages', [ + { id: 'msg1', rid: 'room1', msg: 'hello', status: 1 }, // TEMP — port + { id: 'msg2', rid: 'room1', msg: 'world', status: 2 }, // ERROR — port + { id: 'msg3', rid: 'room1', msg: 'sent', status: 0 } // SENT — skip + ]); + seedLegacyDb(SERVER_DB, 'subscriptions', [ + { id: 'sub1', draft_message: 'my draft' }, + { id: 'sub2', draft_message: '' }, // skip + { id: 'sub3', draft_message: null } // skip + ]); + seedLegacyDb(SERVER_DB, 'threads', [ + { id: 'thr1', draft_message: 'thread draft' }, + { id: 'thr2', draft_message: '' } + ]); + seedLegacyDb(SERVER_DB, 'uploads', [ + { id: 'up1', path: '/uploads/file1', rid: 'room1' }, // file exists + { id: 'up2', path: '/uploads/file2', rid: 'room1' } // file missing + ]); + seedLegacyDb(SERVER_DB, 'frequently_used_emojis', [ + { id: 'emoji1', content: 'wave', extension: 'png', is_custom: 0, count: 5 } + ]); + }); + + it('runs through all phases and marks done', async () => { + await runMigrationIfNeeded(); + expect(isMigrationDone()).toBe(true); + expect(readState()?.phase).toBe('done'); + }); + + it('ports users to the new servers DB with the token as a bound param', async () => { + await runMigrationIfNeeded(); + const writes = mockNewDbWrites['default.db'] ?? []; + const userInsert = writes.find(w => w.sql.includes('INSERT OR REPLACE INTO users')); + expect(userInsert).toBeDefined(); + expect(userInsert!.args).toContain('user1'); + expect(userInsert!.args).toContain('alice'); + // Token is a bound param — never string-concatenated into SQL + expect(userInsert!.sql).not.toContain('tok1'); + expect(userInsert!.args).toContain('tok1'); + }); + + it('drops legacy-only columns (WMDB _status/_changed and dropped columns) from full-row ports', async () => { + // Legacy WMDB rows carry _status/_changed on every table, plus columns the new schema removed. + // A full-row INSERT built from the raw keys would throw "no such column"; insertRows must + // intersect with the new table's actual columns. + seedLegacyDb('default.db', 'users', [ + { id: 'user1', username: 'alice', token: 'tok1', name: 'Alice', _status: 'created', _changed: 'name', legacy_only_col: 'x' } + ]); + await runMigrationIfNeeded(); + const writes = mockNewDbWrites['default.db'] ?? []; + const userInsert = writes.find(w => w.sql.includes('INSERT OR REPLACE INTO users')); + expect(userInsert).toBeDefined(); + expect(userInsert!.sql).not.toContain('_status'); + expect(userInsert!.sql).not.toContain('_changed'); + expect(userInsert!.sql).not.toContain('legacy_only_col'); + // The business columns still make it through, as bound params + expect(userInsert!.args).toContain('user1'); + expect(userInsert!.args).toContain('alice'); + expect(userInsert!.args).not.toContain('x'); + }); + + it('ports server lock fields without clobbering server-synced columns', async () => { + await runMigrationIfNeeded(); + const writes = mockNewDbWrites['default.db'] ?? []; + const lockUpdate = writes.find(w => w.sql.includes('UPDATE servers SET') && w.sql.includes('auto_lock')); + expect(lockUpdate).toBeDefined(); + expect(lockUpdate!.args).toEqual([1, 300, 9999, 0, 'https://open.rocket.chat']); + }); + + it('ports only status 1 and 2 messages', async () => { + await runMigrationIfNeeded(); + const writes = mockNewDbWrites[SERVER_DB] ?? []; + const msgInserts = writes.filter(w => w.sql.includes('INSERT OR REPLACE INTO messages')); + const ids = msgInserts.flatMap(w => w.args).filter(v => typeof v === 'string' && v.startsWith('msg')); + expect(ids).toContain('msg1'); + expect(ids).toContain('msg2'); + expect(ids).not.toContain('msg3'); + }); + + it('ports only non-empty subscription drafts', async () => { + await runMigrationIfNeeded(); + const writes = mockNewDbWrites[SERVER_DB] ?? []; + const draftUpdates = writes.filter(w => w.sql.includes('draft_message') && w.sql.includes('subscriptions')); + const sub1Update = draftUpdates.find(w => w.args.includes('sub1')); + const sub2Update = draftUpdates.find(w => w.args.includes('sub2')); + expect(sub1Update).toBeDefined(); + expect(sub2Update).toBeUndefined(); + expect(sub1Update!.args).toContain('my draft'); + }); + + it('ports only uploads whose file exists', async () => { + await runMigrationIfNeeded(); + const writes = mockNewDbWrites[SERVER_DB] ?? []; + const uploadInserts = writes.filter(w => w.sql.includes('INSERT OR REPLACE INTO uploads')); + const ids = uploadInserts.flatMap(w => w.args).filter(v => typeof v === 'string' && v.startsWith('up')); + expect(ids).toContain('up1'); + expect(ids).not.toContain('up2'); + }); + + it('ports frequently_used_emojis', async () => { + await runMigrationIfNeeded(); + const writes = mockNewDbWrites[SERVER_DB] ?? []; + const emojiInsert = writes.find(w => w.sql.includes('INSERT OR REPLACE INTO frequently_used_emojis')); + expect(emojiInsert).toBeDefined(); + expect(emojiInsert!.args).toContain('emoji1'); + }); + + it('wipes every legacy file after porting', async () => { + await runMigrationIfNeeded(); + expect(mockDeletedDbs).toContain(SERVER_DB); + expect(mockDeletedDbs).toContain('default.db'); + }); +}); + +// --------------------------------------------------------------------------- +// Secure delete — main file + WAL/SHM sidecars +// --------------------------------------------------------------------------- + +describe('secure delete', () => { + const SERVER_DB = 'open.rocket.chat.db'; + + beforeEach(() => { + _setFileExists(path => path.includes('default.db') || path.includes(SERVER_DB)); + seedLegacyDb('default.db', 'users', []); + seedLegacyDb('default.db', 'servers', [{ id: 'https://open.rocket.chat' }]); + seedLegacyDb('default.db', 'servers_history', []); + for (const t of ['messages', 'subscriptions', 'threads', 'uploads', 'frequently_used_emojis']) { + seedLegacyDb(SERVER_DB, t, []); + } + // Simulate WAL/SHM sidecars left on disk next to each legacy DB + for (const db of [SERVER_DB, 'default.db']) { + mockExistingFiles.add(`${mockLegacyDir}/${db}-wal`); + mockExistingFiles.add(`${mockLegacyDir}/${db}-shm`); + } + }); + + it('deletes WAL and SHM sidecars alongside each main DB file', async () => { + await runMigrationIfNeeded(); + for (const db of [SERVER_DB, 'default.db']) { + expect(mockDeletedDbs).toContain(db); + expect(mockDeletedFiles).toContain(`${mockLegacyDir}/${db}-wal`); + expect(mockDeletedFiles).toContain(`${mockLegacyDir}/${db}-shm`); + } + }); +}); + +// --------------------------------------------------------------------------- +// Drafts never clobber server-synced columns +// --------------------------------------------------------------------------- + +describe('draft port preserves server columns', () => { + const SERVER_DB = 'open.rocket.chat.db'; + + beforeEach(() => { + _setFileExists(path => path.includes('default.db') || path.includes(SERVER_DB)); + seedLegacyDb('default.db', 'users', []); + seedLegacyDb('default.db', 'servers', [{ id: 'https://open.rocket.chat' }]); + seedLegacyDb('default.db', 'servers_history', []); + seedLegacyDb(SERVER_DB, 'messages', []); + seedLegacyDb(SERVER_DB, 'subscriptions', [{ id: 'sub1', draft_message: 'unsent text' }]); + seedLegacyDb(SERVER_DB, 'threads', [{ id: 'thr1', draft_message: 'thread unsent' }]); + seedLegacyDb(SERVER_DB, 'uploads', []); + seedLegacyDb(SERVER_DB, 'frequently_used_emojis', []); + }); + + it('writes subscription drafts via INSERT OR IGNORE + UPDATE, never INSERT OR REPLACE', async () => { + await runMigrationIfNeeded(); + const writes = mockNewDbWrites[SERVER_DB] ?? []; + const subWrites = writes.filter(w => w.sql.includes('subscriptions')); + // Only the draft column is touched — a full-row REPLACE would null the columns the server later syncs + expect(subWrites.some(w => w.sql.includes('INSERT OR REPLACE INTO subscriptions'))).toBe(false); + expect(subWrites.some(w => w.sql.includes('INSERT OR IGNORE INTO subscriptions'))).toBe(true); + const draftUpdate = subWrites.find(w => w.sql.includes('UPDATE subscriptions SET draft_message')); + expect(draftUpdate!.args).toEqual(['unsent text', 'sub1']); + }); + + it('writes thread drafts via INSERT OR IGNORE + UPDATE, never INSERT OR REPLACE', async () => { + await runMigrationIfNeeded(); + const writes = mockNewDbWrites[SERVER_DB] ?? []; + const threadWrites = writes.filter(w => w.sql.includes('threads')); + expect(threadWrites.some(w => w.sql.includes('INSERT OR REPLACE INTO threads'))).toBe(false); + expect(threadWrites.some(w => w.sql.includes('INSERT OR IGNORE INTO threads'))).toBe(true); + const draftUpdate = threadWrites.find(w => w.sql.includes('UPDATE threads SET draft_message')); + expect(draftUpdate!.args).toEqual(['thread unsent', 'thr1']); + }); +}); + +// --------------------------------------------------------------------------- +// Crash-resume +// --------------------------------------------------------------------------- + +describe('crash-resume', () => { + it('resumes at porting_active without re-porting the servers DB', async () => { + const SERVER_DB = 'open.rocket.chat.db'; + _setFileExists(path => path.includes('default.db') || path.includes(SERVER_DB)); + seedLegacyDb('default.db', 'users', [{ id: 'u1', username: 'bob' }]); + seedLegacyDb('default.db', 'servers', [{ id: 'https://open.rocket.chat' }]); + seedLegacyDb('default.db', 'servers_history', []); + seedLegacyDb(SERVER_DB, 'messages', []); + seedLegacyDb(SERVER_DB, 'subscriptions', []); + seedLegacyDb(SERVER_DB, 'threads', []); + seedLegacyDb(SERVER_DB, 'uploads', []); + seedLegacyDb(SERVER_DB, 'frequently_used_emojis', []); + + // Simulate a crash after porting_servers: state persisted at porting_active + mockMmkvStore.set( + MIGRATION_KEY, + JSON.stringify({ + schema: 1, + phase: 'porting_active', + servers: { 'https://open.rocket.chat': 'pending' }, + startedAt: fakeNow, + updatedAt: fakeNow + }) + ); + + await runMigrationIfNeeded(); + + expect(isMigrationDone()).toBe(true); + // The servers DB must not be written during a porting_active resume + const serverDbWrites = mockNewDbWrites['default.db'] ?? []; + expect(serverDbWrites.length).toBe(0); + expect(readState()?.servers['https://open.rocket.chat']).toBe('wiped'); + }); +}); + +// --------------------------------------------------------------------------- +// startPortingActive — atomic phase + servers write (KtjSl fix) +// --------------------------------------------------------------------------- + +describe('startPortingActive', () => { + it('writes phase and all server URLs as pending in one state object', () => { + const urls = ['https://open.rocket.chat', 'https://other.example.com']; + startPortingActive(urls); + const state = readState(); + expect(state?.phase).toBe('porting_active'); + expect(state?.servers).toEqual({ + 'https://open.rocket.chat': 'pending', + 'https://other.example.com': 'pending' + }); + }); + + it('writes an empty servers map when given an empty url list', () => { + startPortingActive([]); + const state = readState(); + expect(state?.phase).toBe('porting_active'); + expect(state?.servers).toEqual({}); + }); +}); + +// --------------------------------------------------------------------------- +// Idempotency +// --------------------------------------------------------------------------- + +describe('idempotency', () => { + it('skips on a second run once legacy files are gone', async () => { + _setFileExists(path => path.includes('default.db')); + seedLegacyDb('default.db', 'users', [{ id: 'u1', username: 'carol' }]); + seedLegacyDb('default.db', 'servers', []); + seedLegacyDb('default.db', 'servers_history', []); + + await runMigrationIfNeeded(); + expect(isMigrationDone()).toBe(true); + + // Re-run from scratch: state cleared, legacy files wiped → detect finds nothing → skipped + mockMmkvStore.clear(); + for (const k of Object.keys(mockNewDbWrites)) delete mockNewDbWrites[k]; + for (const k of Object.keys(mockNewSqliteMocks)) delete mockNewSqliteMocks[k]; + mockDeletedDbs.length = 0; + _clearRegistry(); + _setFileExists(() => false); + + await runMigrationIfNeeded(); + expect(isMigrationDone()).toBe(true); + const secondRunWrites = Object.values(mockNewDbWrites).flat().length; + expect(secondRunWrites).toBe(0); + }); +}); diff --git a/app/lib/database/migration/legacyReader.ts b/app/lib/database/migration/legacyReader.ts new file mode 100644 index 00000000000..15e07e1145d --- /dev/null +++ b/app/lib/database/migration/legacyReader.ts @@ -0,0 +1,220 @@ +/** + * Read-only access to legacy WatermelonDB plaintext SQLite files. + * + * SQLCipher is compiled into expo-sqlite but reads plaintext files without a key + * (omitting PRAGMA key means SQLCipher treats the file as unencrypted). No key + * material is written, read, or logged here. + * + * iOS layout: WMDB received `dbName = .db` — an absolute path, so the native + * adapter (WMDatabaseDriver `pathForName:`) used it verbatim. Legacy files therefore live at the + * App Group container ROOT with a SINGLE `.db` (`/default.db`, `/open.rocket.chat.db`). + * New encrypted DBs live in `/SQLite/`. That separation lets the migration read one and write the other. + * + * Android layout: WMDB received a bare `dbName = .db` (appGroupPath is empty on Android), then + * `WMDatabase.createSQLiteDatabase` ran `context.getDatabasePath(name + ".db").replace("/databases", "")`. + * That appends a SECOND `.db` and strips the `/databases` segment, so legacy files live at the app-data + * ROOT (the parent of the files dir) with a DOUBLE `.db.db` suffix (`/default.db.db`, + * `/open.rocket.chat.db.db`). Reading `/databases` or a single-`.db` name finds nothing. + */ + +import { Platform } from 'react-native'; +import { openDatabaseAsync, type SQLiteDatabase } from 'expo-sqlite'; +import { File, Paths } from 'expo-file-system'; + +const APP_GROUP_ID = 'group.ios.chat.rocket'; + +/** + * The on-disk suffix WMDB used for legacy plaintext files. iOS got a single `.db` (RC passed an + * absolute path the native adapter used verbatim); Android got `.db.db` (the native adapter appended + * a second `.db` to RC's already-`.db`-terminated name). See the file header for the full derivation. + */ +const LEGACY_DB_SUFFIX = Platform.OS === 'android' ? '.db.db' : '.db'; + +/** The legacy global/servers DB filename, platform-aware. */ +export const LEGACY_SERVERS_DB_NAME = `default${LEGACY_DB_SUFFIX}`; + +// --------------------------------------------------------------------------- +// Directory resolution +// --------------------------------------------------------------------------- + +/** + * Returns the directory that contains legacy WatermelonDB plaintext files, or undefined + * when it cannot be determined (unit tests, fresh Android install without the folder). + * + * iOS — App Group container root (no subdirectory). + * Android — the app-data root (parent of the files dir), where WMDB landed after stripping `/databases`. + */ +export function resolveLegacyDbDirectory(): string | undefined { + if (Platform.OS === 'ios') { + try { + const containers = Paths.appleSharedContainers as Record; + const container = containers[APP_GROUP_ID]; + if (!container?.uri) { + console.warn('[migration/legacyReader] App Group container not found — cannot read legacy DBs'); + return undefined; + } + // Strip trailing slash; expo-sqlite wants a bare directory path + return container.uri.replace(/\/$/, ''); + } catch (e) { + console.warn('[migration/legacyReader] Failed to resolve App Group container:', (e as Error).message); + return undefined; + } + } + + // Android: WMDB stripped the `/databases` segment, so legacy files sit in the app-data root — + // the parent of the files dir (files dir is `/files`). + try { + // Paths.document is a Directory object (not a string); .uri gives the file:// URI string + const docDir = (Paths as unknown as Record).document; + const filesDir: string | undefined = docDir?.uri; + if (!filesDir) { + console.warn('[migration/legacyReader] Could not resolve filesDir on Android — legacy DB location unknown'); + return undefined; + } + const base = filesDir.replace(/\/$/, ''); + return base.substring(0, base.lastIndexOf('/')); + } catch (e) { + console.warn('[migration/legacyReader] Android legacy DB directory resolution failed:', (e as Error).message); + return undefined; + } +} + +// Resolved once at module load — the container path is stable for the process lifetime. +// Exported for tests to override via jest.mock or module-level patching. +export let LEGACY_DIR: string | undefined = resolveLegacyDbDirectory(); + +/** Injectable for tests that cannot mock expo-file-system at module load. */ +export function _setLegacyDir(dir: string | undefined): void { + LEGACY_DIR = dir; +} + +// --------------------------------------------------------------------------- +// File existence helpers +// --------------------------------------------------------------------------- + +/** Injectable file-existence check — real impl uses expo-file-system File; tests mock this. */ +export let fileExists: (path: string) => boolean = path => { + try { + return new File(path).exists; + } catch { + return false; + } +}; + +export function _setFileExists(fn: (path: string) => boolean): void { + fileExists = fn; +} + +/** + * Returns true when a legacy DB file for `dbName` exists in the legacy directory. + * Checks only the `.db` main file, not sidecars. + */ +export function legacyFileExists(dbName: string): boolean { + if (!LEGACY_DIR) return false; + return fileExists(`${LEGACY_DIR}/${dbName}`); +} + +/** + * Mirrors the legacy WMDB on-disk per-server filename: strip scheme, replace slashes with dots, + * append the platform-aware legacy suffix (`.db` on iOS, `.db.db` on Android). + * + * LEGACY-file address only — distinct from `connection.deriveServerDbName`, which names the + * NEW encrypted files with a single `.db` on both platforms. + */ +export function deriveLegacyServerDbName(serverUrl: string): string { + const sanitized = serverUrl + .replace(/\/+$/, '') + .replace(/(^\w+:|^)\/\//, '') + .replace(/\//g, '.'); + return `${sanitized}${LEGACY_DB_SUFFIX}`; +} + +// --------------------------------------------------------------------------- +// Open +// --------------------------------------------------------------------------- + +/** + * Opens a legacy plaintext SQLite file read-only (no PRAGMA key). + * Caller is responsible for closing the handle when done. + */ +export async function openLegacy(dbName: string): Promise { + // No PRAGMA key — SQLCipher opens plaintext files transparently without one. + // Open with the default options; enableChangeListener not needed (read-only usage). + const db = await openDatabaseAsync(dbName, {}, LEGACY_DIR); + // Verify the file is actually readable before returning + try { + await db.getFirstAsync('SELECT count(*) FROM sqlite_master;'); + } catch (e) { + await db.closeAsync().catch(() => {}); + // Log only the db name, never path or content that might contain key material + throw new Error(`[migration/legacyReader] Cannot read legacy DB '${dbName}': file missing or corrupt`); + } + return db; +} + +// --------------------------------------------------------------------------- +// Raw SELECT helpers — return plain row objects via getAllAsync +// --------------------------------------------------------------------------- + +/** All rows from legacy servers DB `users` table. */ +export function readLegacyUsers(db: SQLiteDatabase): Promise[]> { + return db.getAllAsync('SELECT * FROM users;') as Promise[]>; +} + +type LegacyServerLockFields = { + id: string; + auto_lock: number | null; + auto_lock_time: number | null; + last_local_authenticated_session: number | null; + biometry: number | null; +}; + +/** + * Lock fields from legacy servers DB `servers` table. + * Only the fields that are user-authored / device-local are ported; everything else resyncs. + */ +export function readLegacyServerLockFields(db: SQLiteDatabase): Promise { + return db.getAllAsync( + 'SELECT id, auto_lock, auto_lock_time, last_local_authenticated_session, biometry FROM servers;' + ) as Promise; +} + +/** All rows from legacy servers DB `servers_history` table. */ +export function readLegacyServersHistory(db: SQLiteDatabase): Promise[]> { + return db.getAllAsync('SELECT * FROM servers_history;') as Promise[]>; +} + +/** + * Pending-send messages: status 1 (TEMP) or 2 (ERROR). + * Everything else is resynced from the server. + */ +export function readLegacyPendingMessages(db: SQLiteDatabase): Promise[]> { + return db.getAllAsync('SELECT * FROM messages WHERE status IN (1, 2);') as Promise[]>; +} + +/** Subscriptions with a non-empty draft_message. */ +export function readLegacyDraftSubscriptions(db: SQLiteDatabase): Promise<{ id: string; draft_message: string }[]> { + return db.getAllAsync( + "SELECT id, draft_message FROM subscriptions WHERE draft_message IS NOT NULL AND draft_message != '';" + ) as Promise<{ id: string; draft_message: string }[]>; +} + +/** Threads with a non-empty draft_message. */ +export function readLegacyDraftThreads(db: SQLiteDatabase): Promise<{ id: string; draft_message: string }[]> { + return db.getAllAsync( + "SELECT id, draft_message FROM threads WHERE draft_message IS NOT NULL AND draft_message != '';" + ) as Promise<{ id: string; draft_message: string }[]>; +} + +/** + * All uploads rows. The orchestrator filters to only those whose file still exists on disk. + * We fetch all here; filtering in port.ts keeps the SQL simple and the boundary clear. + */ +export function readLegacyUploads(db: SQLiteDatabase): Promise[]> { + return db.getAllAsync('SELECT * FROM uploads;') as Promise[]>; +} + +/** All frequently_used_emojis rows. */ +export function readLegacyFrequentlyUsedEmojis(db: SQLiteDatabase): Promise[]> { + return db.getAllAsync('SELECT * FROM frequently_used_emojis;') as Promise[]>; +} diff --git a/app/lib/database/migration/orchestrator.ts b/app/lib/database/migration/orchestrator.ts new file mode 100644 index 00000000000..3499b288d24 --- /dev/null +++ b/app/lib/database/migration/orchestrator.ts @@ -0,0 +1,224 @@ +/** + * Migration orchestrator — one-shot entry point for the wipe-and-restore migration. + * + * Crash safety: each phase transition is recorded in MMKV before the destructive + * step that follows it. A crash leaves the phase at the last durable write, so + * re-running resumes from that phase rather than from scratch. + * + * Idempotency: reads are non-destructive; additive writes use INSERT OR REPLACE while + * drafts/lock fields use INSERT OR IGNORE + UPDATE; unlink of a missing file is silently + * swallowed. Running twice produces the same result. + * + * Invoked at the top of the init saga's restore (the APP.INIT handler), before any server + * data is read or re-auth is evaluated, while the bootsplash is still up. + */ + +// Per-server porting and wiping run one server at a time: each is marked done before the next so a +// crash resumes cleanly. The ordering is the crash-safety guarantee, so the loops await in sequence. +/* eslint-disable no-await-in-loop */ +import { deleteDatabaseAsync } from 'expo-sqlite'; +import { File } from 'expo-file-system'; + +import { isMigrationDone, readState, setPhase, markServer, markDone, markSkipped, startPortingActive } from './state'; +import { + LEGACY_DIR, + LEGACY_SERVERS_DB_NAME, + legacyFileExists, + openLegacy, + deriveLegacyServerDbName, + readLegacyUsers, + readLegacyServerLockFields, + readLegacyServersHistory, + readLegacyPendingMessages, + readLegacyDraftSubscriptions, + readLegacyDraftThreads, + readLegacyUploads, + readLegacyFrequentlyUsedEmojis +} from './legacyReader'; +import { + portUsers, + portServerLockFields, + portServersHistory, + portPendingMessages, + portSubscriptionDrafts, + portThreadDrafts, + portUploads, + portFrequentlyUsedEmojis +} from './port'; +import { openServersDb, openServerDb } from '../driver/connection'; + +/** + * Deletes a legacy plaintext DB file and its WAL/SHM sidecars. Idempotent. + * + * No secure-overwrite pass: flash storage wear-levels writes onto fresh physical blocks, so + * overwriting a file's bytes does not erase the original data — an overwrite would be security + * theater. Residual bytes in freed blocks are protected at rest by the OS (iOS Data Protection, + * Android file-based encryption), not by anything done here. The reachable goal is a reliable + * unlink of the main file plus both sidecars. + */ +export async function secureDelete(dir: string | undefined, dbName: string): Promise { + // Main file: deleteDatabaseAsync also tears down any open handle and, on iOS, the sidecars. + try { + await deleteDatabaseAsync(dbName, dir); + } catch { + // Missing file is not an error — unlink is idempotent + } + // WAL/SHM sidecars: deleteDatabaseAsync may leave these behind on Android. Remove explicitly. + if (!dir) return; + for (const suffix of ['-wal', '-shm']) { + try { + const sidecar = new File(`${dir}/${dbName}${suffix}`); + if (sidecar.exists) sidecar.delete(); + } catch { + // Best-effort: an absent or already-removed sidecar is fine + } + } +} + +// --------------------------------------------------------------------------- +// Internal helpers +// --------------------------------------------------------------------------- + +/** + * Reads the list of server URLs from the legacy servers DB. + * Returns an empty array when the table doesn't exist or the DB can't be read. + */ +async function readLegacyServerUrls(legacyServersDb: Awaited>): Promise { + try { + const rows = (await legacyServersDb.getAllAsync('SELECT id FROM servers;')) as { id: string }[]; + return rows.map(r => r.id).filter(Boolean); + } catch { + return []; + } +} + +// --------------------------------------------------------------------------- +// Orchestrator +// --------------------------------------------------------------------------- + +/** + * Runs the wipe-and-restore migration if needed. + * Safe to call on every app boot — the MMKV fast-path makes subsequent calls O(1). + */ +export async function runMigrationIfNeeded(): Promise { + // Fast path: already done (the overwhelming majority of boots after first upgrade) + if (isMigrationDone()) return; + + // Resume from the recorded phase, or start fresh + let state = readState(); + + // ----------------------------------------------------------------------- + // detect — enumerate legacy files; skip if none present (fresh install) + // ----------------------------------------------------------------------- + if ((state?.phase ?? 'detect') === 'detect') { + const hasServersDb = legacyFileExists(LEGACY_SERVERS_DB_NAME); + if (!hasServersDb) { + // No legacy files — fresh install or already wiped; nothing to migrate + markSkipped(); + return; + } + // Initialise state with phase=porting_servers; server list populated after we read the DB + setPhase('porting_servers'); + state = readState(); + } + + // ----------------------------------------------------------------------- + // porting_servers — port users + server lock fields + servers_history + // ----------------------------------------------------------------------- + if (state?.phase === 'porting_servers') { + const legacyDb = await openLegacy(LEGACY_SERVERS_DB_NAME); + try { + const { sqlite: newSqlite } = await openServersDb(); + + const [users, lockFields, history] = await Promise.all([ + readLegacyUsers(legacyDb), + readLegacyServerLockFields(legacyDb), + readLegacyServersHistory(legacyDb) + ]); + + await portUsers(users, newSqlite); + await portServerLockFields(lockFields, newSqlite); + await portServersHistory(history, newSqlite); + + // Capture server URLs before closing the legacy DB; we need them for porting_active + const serverUrls = await readLegacyServerUrls(legacyDb); + + // Atomically advance to porting_active with every server URL marked pending + startPortingActive(serverUrls); + state = readState(); + } finally { + await legacyDb.closeAsync().catch(() => {}); + } + } + + // ----------------------------------------------------------------------- + // porting_active — port app DB data for each server + // ----------------------------------------------------------------------- + if (state?.phase === 'porting_active') { + const serverEntries = Object.entries(state.servers); + for (const [url, status] of serverEntries) { + if (status === 'ported' || status === 'wiped') continue; + + const dbName = deriveLegacyServerDbName(url); + if (!legacyFileExists(dbName)) { + // Legacy per-server DB missing — mark ported so wiping doesn't try to unlink it + markServer(url, 'ported'); + continue; + } + + const legacyDb = await openLegacy(dbName); + try { + const { sqlite: newSqlite } = await openServerDb(url); + + const [pendingMessages, draftSubs, draftThreads, uploads, emojis] = await Promise.all([ + readLegacyPendingMessages(legacyDb), + readLegacyDraftSubscriptions(legacyDb), + readLegacyDraftThreads(legacyDb), + readLegacyUploads(legacyDb), + readLegacyFrequentlyUsedEmojis(legacyDb) + ]); + + await portPendingMessages(pendingMessages, newSqlite); + await portSubscriptionDrafts(draftSubs, newSqlite); + await portThreadDrafts(draftThreads, newSqlite); + await portUploads(uploads, newSqlite); + await portFrequentlyUsedEmojis(emojis, newSqlite); + + markServer(url, 'ported'); + } finally { + await legacyDb.closeAsync().catch(() => {}); + } + } + + setPhase('wiping'); + state = readState(); + } + + // ----------------------------------------------------------------------- + // wiping — delete each legacy file + sidecars, then the servers DB + // ----------------------------------------------------------------------- + if (state?.phase === 'wiping') { + for (const [url, status] of Object.entries(state.servers)) { + if (status === 'wiped') continue; + const dbName = deriveLegacyServerDbName(url); + await secureDelete(LEGACY_DIR, dbName); + markServer(url, 'wiped'); + } + // Wipe the servers DB last — it was the entry point for detect + await secureDelete(LEGACY_DIR, LEGACY_SERVERS_DB_NAME); + + setPhase('finalizing'); + state = readState(); + } + + // ----------------------------------------------------------------------- + // finalizing — mark done + // ----------------------------------------------------------------------- + if (state?.phase === 'finalizing') { + // No backup-exclusion step: the new DB is SQLCipher-encrypted with a device-only key + // (iOS kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly, non-synchronizable; Android Keystore) + // and Android sets allowBackup=false app-wide. A backed-up DB is ciphertext whose key can never + // be in the backup, so excluding the file would add nothing. + markDone(); + } +} diff --git a/app/lib/database/migration/port.ts b/app/lib/database/migration/port.ts new file mode 100644 index 00000000000..a272da3ada9 --- /dev/null +++ b/app/lib/database/migration/port.ts @@ -0,0 +1,161 @@ +/** + * Writes the port set from legacy plaintext rows into the new encrypted DB. + * + * All writes use INSERT OR REPLACE with bound params — idempotent by primary key `id`, + * safe to re-run after a crash mid-phase. Values are NEVER string-concatenated. + * + * The new DB's raw `sqlite` handle is used directly, bypassing the facade. The facade's + * prepareCreate defaults every schema column (sanitizedRaw) before insert, so using it to seed + * a drafts-only row would write null defaults across the ~50 columns the server later syncs. + * The migration instead touches only the columns it owns: additive tables use INSERT OR REPLACE + * keyed by `id`; drafts and lock fields use INSERT OR IGNORE(id) + UPDATE so a row the server + * recreates keeps its synced columns. All writes are idempotent and re-run safely after a crash. + * + * Column drift: legacy WatermelonDB tables carry internal `_status`/`_changed` columns the new + * drizzle schema does not, and other columns may have been dropped or renamed across the two + * schema versions. A full-row `INSERT (col, ...)` built from the legacy row's keys would throw + * "no such column" on any legacy-only column. Every additive port intersects the legacy row's + * columns with the new table's actual columns (read via PRAGMA table_info) and inserts only that + * intersection — omitted columns are nullable or server-synced, so dropping them is safe. + */ + +// Row writes run sequentially on a single SQLite connection (which serializes writes anyway); +// parallelising them buys nothing and loses the deterministic, resume-safe ordering the migration needs. +/* eslint-disable no-await-in-loop */ +import type { SQLiteDatabase } from 'expo-sqlite'; + +import type { readLegacyServerLockFields, readLegacyDraftSubscriptions, readLegacyDraftThreads } from './legacyReader'; +import { fileExists } from './legacyReader'; + +// Re-export type alias for clarity in orchestrator +export type LegacyRow = Record; + +// --------------------------------------------------------------------------- +// Column-drift-safe additive insert +// --------------------------------------------------------------------------- + +/** + * Reads the actual column names of `tableName` in the new DB. + * `tableName` is always a hardcoded literal from the callers below — never user input — + * so interpolating it into the PRAGMA (which cannot take a bound param) is safe. + */ +async function targetColumns(tableName: string, newSqlite: SQLiteDatabase): Promise> { + const info = (await newSqlite.getAllAsync(`PRAGMA table_info(${tableName});`)) as { name: string }[]; + return new Set(info.map(c => c.name)); +} + +/** + * INSERT OR REPLACE each legacy row into `tableName`, restricted to columns the new table has. + * Idempotent by primary key `id`; values are always bound params. + */ +async function insertRows(tableName: string, legacyRows: LegacyRow[], newSqlite: SQLiteDatabase): Promise { + if (legacyRows.length === 0) return; + const allowed = await targetColumns(tableName, newSqlite); + for (const row of legacyRows) { + const cols = Object.keys(row).filter(c => allowed.has(c)); + if (cols.length === 0) continue; + const placeholders = cols.map(() => '?').join(', '); + const values = cols.map(c => row[c]); + await newSqlite.runAsync( + `INSERT OR REPLACE INTO ${tableName} (${cols.join(', ')}) VALUES (${placeholders});`, + values as never[] + ); + } +} + +// --------------------------------------------------------------------------- +// Servers DB port +// --------------------------------------------------------------------------- + +/** + * Ports all columns of the legacy `users` table into the new servers DB. + * Token values are bound params — never appear in logs or error messages. + */ +export async function portUsers(legacyRows: LegacyRow[], newSqlite: SQLiteDatabase): Promise { + await insertRows('users', legacyRows, newSqlite); +} + +/** + * Upserts only the four lock fields into the new servers DB `servers` table. + * Rows that don't exist yet in the new DB are skipped (INSERT OR IGNORE + UPDATE). + * Using INSERT OR IGNORE + UPDATE ensures we never blow away server-synced fields + * while still writing the lock fields whether or not the server row exists yet. + */ +export async function portServerLockFields( + legacyRows: Awaited>, + newSqlite: SQLiteDatabase +): Promise { + for (const row of legacyRows) { + // Ensure the row exists first (it may not if the new DB hasn't synced yet) + await newSqlite.runAsync('INSERT OR IGNORE INTO servers (id) VALUES (?);', [row.id]); + await newSqlite.runAsync( + `UPDATE servers SET + auto_lock = ?, + auto_lock_time = ?, + last_local_authenticated_session = ?, + biometry = ? + WHERE id = ?;`, + [row.auto_lock, row.auto_lock_time, row.last_local_authenticated_session, row.biometry, row.id] + ); + } +} + +/** + * Ports all columns of the legacy `servers_history` table. + */ +export async function portServersHistory(legacyRows: LegacyRow[], newSqlite: SQLiteDatabase): Promise { + await insertRows('servers_history', legacyRows, newSqlite); +} + +// --------------------------------------------------------------------------- +// App DB port +// --------------------------------------------------------------------------- + +/** + * Ports pending-send messages (status 1 = TEMP, 2 = ERROR). + * All columns from the legacy row are written; the server will update or discard on next sync. + */ +export async function portPendingMessages(legacyRows: LegacyRow[], newSqlite: SQLiteDatabase): Promise { + await insertRows('messages', legacyRows, newSqlite); +} + +// tableName is always a hardcoded literal — never user input — so interpolation is safe. +async function portDraftColumn( + tableName: string, + legacyRows: { id: string; draft_message: string }[], + newSqlite: SQLiteDatabase +): Promise { + for (const row of legacyRows) { + await newSqlite.runAsync(`INSERT OR IGNORE INTO ${tableName} (id) VALUES (?);`, [row.id]); + await newSqlite.runAsync(`UPDATE ${tableName} SET draft_message = ? WHERE id = ?;`, [row.draft_message, row.id]); + } +} + +export const portSubscriptionDrafts = ( + legacyRows: Awaited>, + newSqlite: SQLiteDatabase +): Promise => portDraftColumn('subscriptions', legacyRows, newSqlite); + +export const portThreadDrafts = ( + legacyRows: Awaited>, + newSqlite: SQLiteDatabase +): Promise => portDraftColumn('threads', legacyRows, newSqlite); + +/** + * Ports upload rows whose backing file still exists on disk. + * Rows with a missing file are dropped — retrying a dead upload after migration would fail anyway. + */ +export async function portUploads(legacyRows: LegacyRow[], newSqlite: SQLiteDatabase): Promise { + const live = legacyRows.filter(row => { + const { path } = row; + return typeof path === 'string' && path.length > 0 && fileExists(path); + }); + await insertRows('uploads', live, newSqlite); +} + +/** + * Ports all frequently_used_emojis rows. + */ +export async function portFrequentlyUsedEmojis(legacyRows: LegacyRow[], newSqlite: SQLiteDatabase): Promise { + await insertRows('frequently_used_emojis', legacyRows, newSqlite); +} diff --git a/app/lib/database/migration/state.ts b/app/lib/database/migration/state.ts new file mode 100644 index 00000000000..b623c815af7 --- /dev/null +++ b/app/lib/database/migration/state.ts @@ -0,0 +1,143 @@ +/** + * MMKV-backed state machine for the wipe-and-restore DB migration. + * + * Two keys: + * `db_migration:v1:done` — fast boolean, checked on every boot to skip the JSON parse entirely + * `db_migration:v1` — full state JSON, only read/written during an active migration + * + * Phase sequence: + * detect → porting_servers → porting_active → wiping → finalizing → done + * detect → skipped (fresh install: no legacy files found) + * + * The `done` fast-path key is written last, AFTER the state JSON records 'done', + * so a crash between the two leaves the state JSON as the authoritative source. + */ + +import userPreferences from '../../methods/userPreferences'; + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +export type MigrationPhase = 'detect' | 'porting_servers' | 'porting_active' | 'wiping' | 'finalizing' | 'done' | 'skipped'; + +export type ServerStatus = 'pending' | 'ported' | 'wiped'; + +export interface MigrationState { + schema: 1; + phase: MigrationPhase; + /** Keyed by server URL; only populated after detect enumerates legacy files. */ + servers: Record; + startedAt: number; + updatedAt: number; +} + +// --------------------------------------------------------------------------- +// MMKV keys +// --------------------------------------------------------------------------- + +/** Exported for test-only use — reference these from tests instead of duplicating the string. */ +export const MIGRATION_KEY = 'db_migration:v1'; +export const MIGRATION_DONE_KEY = 'db_migration:v1:done'; + +const KEY_STATE = MIGRATION_KEY; +const KEY_DONE = MIGRATION_DONE_KEY; + +// --------------------------------------------------------------------------- +// Time injection — allows tests to fix timestamps without mocking Date globally +// --------------------------------------------------------------------------- + +/** Overridable in tests: set to a fixed value before calling any state mutator. */ +export let getNowMs: () => number = () => Date.now(); + +/** Injectable for tests — replaces the module-level time source. */ +export function _setNowMs(fn: () => number): void { + getNowMs = fn; +} + +// --------------------------------------------------------------------------- +// Public API +// --------------------------------------------------------------------------- + +/** + * O(1) fast path: returns true when migration is fully done. + * Falls through to `readState()` only when the boolean flag is missing, + * which happens only during an in-progress migration or the very first boot. + */ +export function isMigrationDone(): boolean { + const flag = userPreferences.getBool(KEY_DONE); + if (flag === true) return true; + + // Boolean flag absent — check the full state JSON to cover the crash window + // where the state was set to 'done' but the boolean key wasn't written yet. + const state = readState(); + return state?.phase === 'done' || state?.phase === 'skipped'; +} + +/** Returns the full migration state or null if never started. Rejects corrupt/unknown-schema entries to avoid misrouting. */ +export function readState(): MigrationState | null { + const raw = userPreferences.getMap(KEY_STATE); + if (!raw || typeof raw !== 'object') return null; + if ((raw as { schema?: unknown }).schema !== 1) return null; + const { phase } = raw as { phase?: unknown }; + const KNOWN: MigrationPhase[] = ['detect', 'porting_servers', 'porting_active', 'wiping', 'finalizing', 'done', 'skipped']; + if (typeof phase !== 'string' || !KNOWN.includes(phase as MigrationPhase)) return null; + return raw as MigrationState; +} + +/** Writes the full migration state object. */ +export function writeState(state: MigrationState): void { + userPreferences.setMap(KEY_STATE, state); +} + +/** + * Sets the phase field and bumps updatedAt. + * Initialises a fresh state if one does not yet exist. + */ +export function setPhase(phase: MigrationPhase): void { + const now = getNowMs(); + const existing = readState(); + const next: MigrationState = existing + ? { ...existing, phase, updatedAt: now } + : { schema: 1, phase, servers: {}, startedAt: now, updatedAt: now }; + writeState(next); +} + +/** + * Records the status for a single server URL. + * Requires the state to already exist (setPhase must have been called first). + */ +export function markServer(url: string, status: ServerStatus): void { + const state = readState(); + if (!state) throw new Error('markServer called before state was initialised'); + writeState({ ...state, servers: { ...state.servers, [url]: status }, updatedAt: getNowMs() }); +} + +/** + * Transitions to 'done' and writes the fast-path boolean. + * The two writes are not atomic — the boolean is always written AFTER the state JSON. + * A crash between the two is safe: `isMigrationDone()` reads the state JSON as fallback. + */ +export function markDone(): void { + setPhase('done'); + userPreferences.setBool(KEY_DONE, true); +} + +/** Atomically advances to porting_active with every server URL marked pending. */ +export function startPortingActive(serverUrls: string[]): void { + const now = getNowMs(); + const existing = readState(); + writeState({ + schema: 1, + phase: 'porting_active', + servers: Object.fromEntries(serverUrls.map(url => [url, 'pending'])), + startedAt: existing?.startedAt ?? now, + updatedAt: now + }); +} + +/** Transitions to 'skipped' (fresh install / no legacy files found). */ +export function markSkipped(): void { + setPhase('skipped'); + userPreferences.setBool(KEY_DONE, true); +} diff --git a/app/lib/database/model/CustomEmoji.js b/app/lib/database/model/CustomEmoji.js index c8fdedd057c..37d57d044bb 100644 --- a/app/lib/database/model/CustomEmoji.js +++ b/app/lib/database/model/CustomEmoji.js @@ -1,5 +1,4 @@ -import { Model } from '@nozbe/watermelondb'; -import { date, field, json } from '@nozbe/watermelondb/decorators'; +import { Model, date, field, json } from '../facade'; import { sanitizer } from '../utils'; diff --git a/app/lib/database/model/FrequentlyUsedEmoji.js b/app/lib/database/model/FrequentlyUsedEmoji.js index 2c6c815edf9..9a28581ab00 100644 --- a/app/lib/database/model/FrequentlyUsedEmoji.js +++ b/app/lib/database/model/FrequentlyUsedEmoji.js @@ -1,5 +1,4 @@ -import { Model } from '@nozbe/watermelondb'; -import { field } from '@nozbe/watermelondb/decorators'; +import { Model, field } from '../facade'; export const FREQUENTLY_USED_EMOJIS_TABLE = 'frequently_used_emojis'; export default class FrequentlyUsedEmoji extends Model { diff --git a/app/lib/database/model/Message.js b/app/lib/database/model/Message.js index 336f9c39351..db8b76a6104 100644 --- a/app/lib/database/model/Message.js +++ b/app/lib/database/model/Message.js @@ -1,5 +1,4 @@ -import { Model } from '@nozbe/watermelondb'; -import { date, field, json, relation } from '@nozbe/watermelondb/decorators'; +import { Model, date, field, json, relation } from '../facade'; import { sanitizer } from '../utils'; diff --git a/app/lib/database/model/Permission.js b/app/lib/database/model/Permission.js index c397f079a35..183e00b7af6 100644 --- a/app/lib/database/model/Permission.js +++ b/app/lib/database/model/Permission.js @@ -1,5 +1,4 @@ -import { Model } from '@nozbe/watermelondb'; -import { date, json } from '@nozbe/watermelondb/decorators'; +import { Model, date, json } from '../facade'; import { sanitizer } from '../utils'; diff --git a/app/lib/database/model/Role.js b/app/lib/database/model/Role.js index e0633b045f3..8f3be1a6bad 100644 --- a/app/lib/database/model/Role.js +++ b/app/lib/database/model/Role.js @@ -1,5 +1,4 @@ -import { Model } from '@nozbe/watermelondb'; -import { field } from '@nozbe/watermelondb/decorators'; +import { Model, field } from '../facade'; export const ROLES_TABLE = 'roles'; diff --git a/app/lib/database/model/Room.js b/app/lib/database/model/Room.js index e2a1127bf56..f84c5701d76 100644 --- a/app/lib/database/model/Room.js +++ b/app/lib/database/model/Room.js @@ -1,5 +1,4 @@ -import { Model } from '@nozbe/watermelondb'; -import { field, json } from '@nozbe/watermelondb/decorators'; +import { Model, field, json } from '../facade'; import { sanitizer } from '../utils'; diff --git a/app/lib/database/model/ServersHistory.js b/app/lib/database/model/ServersHistory.js index c8651d226ca..f92942e3e27 100644 --- a/app/lib/database/model/ServersHistory.js +++ b/app/lib/database/model/ServersHistory.js @@ -1,5 +1,4 @@ -import { Model } from '@nozbe/watermelondb'; -import { date, field, readonly } from '@nozbe/watermelondb/decorators'; +import { Model, date, field, readonly } from '../facade'; export const SERVERS_HISTORY_TABLE = 'servers_history'; diff --git a/app/lib/database/model/Setting.js b/app/lib/database/model/Setting.js index 1597272bdb1..68e554c69ba 100644 --- a/app/lib/database/model/Setting.js +++ b/app/lib/database/model/Setting.js @@ -1,5 +1,4 @@ -import { Model } from '@nozbe/watermelondb'; -import { date, field, json } from '@nozbe/watermelondb/decorators'; +import { Model, date, field, json } from '../facade'; import { sanitizer } from '../utils'; diff --git a/app/lib/database/model/SlashCommand.js b/app/lib/database/model/SlashCommand.js index 8bcba65f724..84c32f65452 100644 --- a/app/lib/database/model/SlashCommand.js +++ b/app/lib/database/model/SlashCommand.js @@ -1,5 +1,4 @@ -import { Model } from '@nozbe/watermelondb'; -import { field } from '@nozbe/watermelondb/decorators'; +import { Model, field } from '../facade'; export const SLASH_COMMANDS_TABLE = 'slash_commands'; diff --git a/app/lib/database/model/Subscription.js b/app/lib/database/model/Subscription.js index 8c0db9d18ca..6ab7e619ffb 100644 --- a/app/lib/database/model/Subscription.js +++ b/app/lib/database/model/Subscription.js @@ -1,5 +1,4 @@ -import { Model } from '@nozbe/watermelondb'; -import { children, date, field, json } from '@nozbe/watermelondb/decorators'; +import { Model, children, date, field, json } from '../facade'; import { sanitizer } from '../utils'; diff --git a/app/lib/database/model/Thread.js b/app/lib/database/model/Thread.js index a04e589bbda..54a4a8f0c30 100644 --- a/app/lib/database/model/Thread.js +++ b/app/lib/database/model/Thread.js @@ -1,5 +1,4 @@ -import { Model } from '@nozbe/watermelondb'; -import { date, field, json, relation } from '@nozbe/watermelondb/decorators'; +import { Model, date, field, json, relation } from '../facade'; import { sanitizer } from '../utils'; diff --git a/app/lib/database/model/ThreadMessage.js b/app/lib/database/model/ThreadMessage.js index 8bb364b7edf..8f8c861a034 100644 --- a/app/lib/database/model/ThreadMessage.js +++ b/app/lib/database/model/ThreadMessage.js @@ -1,5 +1,4 @@ -import { Model } from '@nozbe/watermelondb'; -import { date, field, json, relation } from '@nozbe/watermelondb/decorators'; +import { Model, date, field, json, relation } from '../facade'; import { sanitizer } from '../utils'; diff --git a/app/lib/database/model/Upload.js b/app/lib/database/model/Upload.js index bfb91655ea8..30525e75a1e 100644 --- a/app/lib/database/model/Upload.js +++ b/app/lib/database/model/Upload.js @@ -1,5 +1,4 @@ -import { Model } from '@nozbe/watermelondb'; -import { field, relation } from '@nozbe/watermelondb/decorators'; +import { Model, field, relation } from '../facade'; export const UPLOADS_TABLE = 'uploads'; diff --git a/app/lib/database/model/User.js b/app/lib/database/model/User.js index 23978d1caf3..b4c51388ff7 100644 --- a/app/lib/database/model/User.js +++ b/app/lib/database/model/User.js @@ -1,5 +1,4 @@ -import { Model } from '@nozbe/watermelondb'; -import { field, json } from '@nozbe/watermelondb/decorators'; +import { Model, field, json } from '../facade'; import { sanitizer } from '../utils'; diff --git a/app/lib/database/model/servers/Server.js b/app/lib/database/model/servers/Server.js index 2d57113e414..229bb0e6cec 100644 --- a/app/lib/database/model/servers/Server.js +++ b/app/lib/database/model/servers/Server.js @@ -1,5 +1,4 @@ -import { Model } from '@nozbe/watermelondb'; -import { date, field, json } from '@nozbe/watermelondb/decorators'; +import { Model, date, field, json } from '../../facade'; import { sanitizer } from '../../utils'; export const SERVERS_TABLE = 'servers'; diff --git a/app/lib/database/model/servers/User.js b/app/lib/database/model/servers/User.js index bf32f82e911..21037314661 100644 --- a/app/lib/database/model/servers/User.js +++ b/app/lib/database/model/servers/User.js @@ -1,5 +1,4 @@ -import { Model } from '@nozbe/watermelondb'; -import { field, json } from '@nozbe/watermelondb/decorators'; +import { Model, field, json } from '../../facade'; import { sanitizer } from '../../utils'; diff --git a/app/lib/database/schema/app.js b/app/lib/database/schema/app.js index ad3cd1659b8..e418e117e07 100644 --- a/app/lib/database/schema/app.js +++ b/app/lib/database/schema/app.js @@ -1,4 +1,4 @@ -import { appSchema, tableSchema } from '@nozbe/watermelondb'; +import { appSchema, tableSchema } from '../facade'; export default appSchema({ version: 29, diff --git a/app/lib/database/schema/servers.js b/app/lib/database/schema/servers.js index 0fd15e9b517..96362030c04 100644 --- a/app/lib/database/schema/servers.js +++ b/app/lib/database/schema/servers.js @@ -1,4 +1,4 @@ -import { appSchema, tableSchema } from '@nozbe/watermelondb'; +import { appSchema, tableSchema } from '../facade'; export default appSchema({ version: 17, diff --git a/app/lib/database/services/Subscription.ts b/app/lib/database/services/Subscription.ts index df7ccd82a6d..6d793821f1a 100644 --- a/app/lib/database/services/Subscription.ts +++ b/app/lib/database/services/Subscription.ts @@ -1,5 +1,4 @@ -import { Q } from '@nozbe/watermelondb'; - +import { Q } from '../facade'; import database from '..'; import { type TSubscriptionModel } from '../../../definitions'; import { type TAppDatabase } from '../interfaces'; diff --git a/app/lib/database/tableMaps.ts b/app/lib/database/tableMaps.ts new file mode 100644 index 00000000000..7eb00a045e6 --- /dev/null +++ b/app/lib/database/tableMaps.ts @@ -0,0 +1,107 @@ +/** + * Maps each WatermelonDB table name to its Drizzle table object and its Model subclass. + * + * The facade Database uses these to (a) resolve the Drizzle table for a query and + * (b) instantiate the correct Model subclass so its @field/@date/@json accessors exist — + * the role WMDB's `modelClasses` array played. + */ + +import type { SQLiteTable } from 'drizzle-orm/sqlite-core'; + +import type { ModelClass } from './facade/Database'; +import { + subscriptionsTable, + roomsTable, + messagesTable, + threadsTable, + threadMessagesTable, + customEmojisTable, + frequentlyUsedEmojisTable, + uploadsTable, + settingsTable, + rolesTable, + permissionsTable, + slashCommandsTable, + usersAppTable, + serversTable, + usersServersTable, + serversHistoryTable +} from './driver/schema'; +import { + SUBSCRIPTIONS_TABLE, + ROOMS_TABLE, + MESSAGES_TABLE, + THREADS_TABLE, + THREAD_MESSAGES_TABLE, + CUSTOM_EMOJIS_TABLE, + FREQUENTLY_USED_EMOJIS_TABLE, + UPLOADS_TABLE, + SETTINGS_TABLE, + ROLES_TABLE, + PERMISSIONS_TABLE, + SLASH_COMMANDS_TABLE, + USERS_TABLE, + SERVERS_TABLE, + LOGGED_USERS_TABLE, + SERVERS_HISTORY_TABLE +} from './model'; +import Subscription from './model/Subscription'; +import Room from './model/Room'; +import Message from './model/Message'; +import Thread from './model/Thread'; +import ThreadMessage from './model/ThreadMessage'; +import CustomEmoji from './model/CustomEmoji'; +import FrequentlyUsedEmoji from './model/FrequentlyUsedEmoji'; +import Upload from './model/Upload'; +import Setting from './model/Setting'; +import Role from './model/Role'; +import Permission from './model/Permission'; +import SlashCommand from './model/SlashCommand'; +import User from './model/User'; +import Server from './model/servers/Server'; +import LoggedUser from './model/servers/User'; +import ServersHistory from './model/ServersHistory'; + +export const appTableMap: Record = { + [SUBSCRIPTIONS_TABLE]: subscriptionsTable, + [ROOMS_TABLE]: roomsTable, + [MESSAGES_TABLE]: messagesTable, + [THREADS_TABLE]: threadsTable, + [THREAD_MESSAGES_TABLE]: threadMessagesTable, + [CUSTOM_EMOJIS_TABLE]: customEmojisTable, + [FREQUENTLY_USED_EMOJIS_TABLE]: frequentlyUsedEmojisTable, + [UPLOADS_TABLE]: uploadsTable, + [SETTINGS_TABLE]: settingsTable, + [ROLES_TABLE]: rolesTable, + [PERMISSIONS_TABLE]: permissionsTable, + [SLASH_COMMANDS_TABLE]: slashCommandsTable, + [USERS_TABLE]: usersAppTable +}; + +export const appModelMap: Record = { + [SUBSCRIPTIONS_TABLE]: Subscription, + [ROOMS_TABLE]: Room, + [MESSAGES_TABLE]: Message, + [THREADS_TABLE]: Thread, + [THREAD_MESSAGES_TABLE]: ThreadMessage, + [CUSTOM_EMOJIS_TABLE]: CustomEmoji, + [FREQUENTLY_USED_EMOJIS_TABLE]: FrequentlyUsedEmoji, + [UPLOADS_TABLE]: Upload, + [SETTINGS_TABLE]: Setting, + [ROLES_TABLE]: Role, + [PERMISSIONS_TABLE]: Permission, + [SLASH_COMMANDS_TABLE]: SlashCommand, + [USERS_TABLE]: User +}; + +export const serversTableMap: Record = { + [SERVERS_TABLE]: serversTable, + [LOGGED_USERS_TABLE]: usersServersTable, + [SERVERS_HISTORY_TABLE]: serversHistoryTable +}; + +export const serversModelMap: Record = { + [SERVERS_TABLE]: Server, + [LOGGED_USERS_TABLE]: LoggedUser, + [SERVERS_HISTORY_TABLE]: ServersHistory +}; diff --git a/app/lib/database/utils.test.ts b/app/lib/database/utils.test.ts index ad859422805..3da61ffd3cf 100644 --- a/app/lib/database/utils.test.ts +++ b/app/lib/database/utils.test.ts @@ -1,10 +1,8 @@ -import type { Q } from '@nozbe/watermelondb'; - +import type { Q } from './facade'; import * as utils from './utils'; // Extracts every `LIKE` value (e.g. '%moy%') present in a serialized Q.or clause -const getLikeValues = (clause: Q.Or): string[] => - (clause as any).conditions.map((condition: any) => condition.comparison.right.value); +const getLikeValues = (clause: Q.Or): string[] => (clause as any).clauses.map((condition: any) => condition.comparison.value); describe('sanitizeLikeStringTester', () => { // example chars that shouldn't return @@ -28,7 +26,7 @@ describe('sanitizeLikeStringTester', () => { }); describe('getSubscriptionSearchClause', () => { - const columnsOf = (clause: Q.Or): string[] => (clause as any).conditions.map((condition: any) => condition.left); + const columnsOf = (clause: Q.Or): string[] => (clause as any).clauses.map((condition: any) => condition.column); test('queries the slugified and sanitized name/fname columns', () => { const clause = utils.getSubscriptionSearchClause('test'); diff --git a/app/lib/database/utils.ts b/app/lib/database/utils.ts index 547582fc871..40fbbfa6c2c 100644 --- a/app/lib/database/utils.ts +++ b/app/lib/database/utils.ts @@ -1,7 +1,8 @@ -import { Q } from '@nozbe/watermelondb'; import XRegExp from 'xregexp'; import { slugify } from 'transliteration'; +import { Q } from './facade'; + // Matches letters from any alphabet and numbers const likeStringRegex = XRegExp('[^\\p{L}\\p{Nd}]', 'g'); export const sanitizeLikeString = (str?: string): string | undefined => str?.replace(likeStringRegex, '_'); diff --git a/app/lib/encryption/encryption.ts b/app/lib/encryption/encryption.ts index ffe13c69655..459345c3809 100644 --- a/app/lib/encryption/encryption.ts +++ b/app/lib/encryption/encryption.ts @@ -1,4 +1,3 @@ -import { type Model, Q } from '@nozbe/watermelondb'; import EJSON from 'ejson'; import { deleteAsync } from 'expo-file-system/legacy'; import { @@ -16,6 +15,7 @@ import { } from '@rocket.chat/mobile-crypto'; import { sampleSize } from 'lodash'; +import { type Model, Q } from '../database/facade'; import { type IMessage, type IServerAttachment, diff --git a/app/lib/methods/AudioManager.ts b/app/lib/methods/AudioManager.ts index ee4b9bec978..ce007638711 100644 --- a/app/lib/methods/AudioManager.ts +++ b/app/lib/methods/AudioManager.ts @@ -1,6 +1,6 @@ import { type AVPlaybackStatus, Audio } from 'expo-av'; -import { Q } from '@nozbe/watermelondb'; +import { Q } from '../database/facade'; import dayjs from '../dayjs'; import { getMessageById } from '../database/services/Message'; import database from '../database'; diff --git a/app/lib/methods/emojis.ts b/app/lib/methods/emojis.ts index 63f4d885a9c..0bea876c619 100644 --- a/app/lib/methods/emojis.ts +++ b/app/lib/methods/emojis.ts @@ -1,5 +1,4 @@ -import { Q } from '@nozbe/watermelondb'; - +import { Q } from '../database/facade'; import database from '../database'; import { type ICustomEmoji, type IEmoji, type TFrequentlyUsedEmojiModel } from '../../definitions'; import log from './helpers/log'; diff --git a/app/lib/methods/getCustomEmojis.ts b/app/lib/methods/getCustomEmojis.ts index a3c986f3a7a..e7f25195517 100644 --- a/app/lib/methods/getCustomEmojis.ts +++ b/app/lib/methods/getCustomEmojis.ts @@ -1,6 +1,6 @@ import orderBy from 'lodash/orderBy'; -import { sanitizedRaw } from '@nozbe/watermelondb/RawRecord'; +import { sanitizedRaw } from '../database/facade'; import { store as reduxStore } from '../store/auxStore'; import database from '../database'; import log from './helpers/log'; diff --git a/app/lib/methods/getPermissions.ts b/app/lib/methods/getPermissions.ts index cf38e96e466..a72894df290 100644 --- a/app/lib/methods/getPermissions.ts +++ b/app/lib/methods/getPermissions.ts @@ -1,7 +1,6 @@ -import { Q } from '@nozbe/watermelondb'; -import { sanitizedRaw } from '@nozbe/watermelondb/RawRecord'; import orderBy from 'lodash/orderBy'; +import { Q, sanitizedRaw } from '../database/facade'; import { setPermissions as setPermissionsAction } from '../../actions/permissions'; import { type IPermission, type TPermissionModel } from '../../definitions'; import log from './helpers/log'; diff --git a/app/lib/methods/getRoles.ts b/app/lib/methods/getRoles.ts index a266b91222c..7747c4033cf 100644 --- a/app/lib/methods/getRoles.ts +++ b/app/lib/methods/getRoles.ts @@ -1,6 +1,5 @@ -import { sanitizedRaw } from '@nozbe/watermelondb/RawRecord'; -import type Model from '@nozbe/watermelondb/Model'; - +import { sanitizedRaw } from '../database/facade'; +import type { Model } from '../database/facade'; import database from '../database'; import { getRoleById } from '../database/services/Role'; import log from './helpers/log'; diff --git a/app/lib/methods/getSettings.ts b/app/lib/methods/getSettings.ts index e1f3a7d733b..499ec68e214 100644 --- a/app/lib/methods/getSettings.ts +++ b/app/lib/methods/getSettings.ts @@ -1,6 +1,4 @@ -import { Q } from '@nozbe/watermelondb'; -import { sanitizedRaw } from '@nozbe/watermelondb/RawRecord'; - +import { Q, sanitizedRaw } from '../database/facade'; import { addSettings, clearSettings } from '../../actions/settings'; import { defaultSettings } from '../constants/defaultSettings'; import { DEFAULT_AUTO_LOCK } from '../constants/localAuthentication'; diff --git a/app/lib/methods/getSlashCommands.ts b/app/lib/methods/getSlashCommands.ts index 8df67d989f4..b6c9460d061 100644 --- a/app/lib/methods/getSlashCommands.ts +++ b/app/lib/methods/getSlashCommands.ts @@ -1,5 +1,4 @@ -import { sanitizedRaw } from '@nozbe/watermelondb/RawRecord'; - +import { sanitizedRaw } from '../database/facade'; import database from '../database'; import log from './helpers/log'; import protectedFunction from './helpers/protectedFunction'; diff --git a/app/lib/methods/getThreadName.ts b/app/lib/methods/getThreadName.ts index 04ed4ac4f24..f29967a8946 100644 --- a/app/lib/methods/getThreadName.ts +++ b/app/lib/methods/getThreadName.ts @@ -1,5 +1,4 @@ -import { sanitizedRaw } from '@nozbe/watermelondb/RawRecord'; - +import { sanitizedRaw } from '../database/facade'; import database from '../database'; import { getMessageById } from '../database/services/Message'; import { getThreadById } from '../database/services/Thread'; diff --git a/app/lib/methods/getUsersPresence.ts b/app/lib/methods/getUsersPresence.ts index edee13dfb7d..da111876cf4 100644 --- a/app/lib/methods/getUsersPresence.ts +++ b/app/lib/methods/getUsersPresence.ts @@ -1,7 +1,6 @@ import { InteractionManager } from 'react-native'; -import { sanitizedRaw } from '@nozbe/watermelondb/RawRecord'; -import { Q } from '@nozbe/watermelondb'; +import { sanitizedRaw, Q } from '../database/facade'; import { type IActiveUsers } from '../../reducers/activeUsers'; import { store as reduxStore } from '../store/auxStore'; import { setActiveUsers } from '../../actions/activeUsers'; diff --git a/app/lib/methods/handleMediaDownload.ts b/app/lib/methods/handleMediaDownload.ts index 8f42fe26224..7131825de24 100644 --- a/app/lib/methods/handleMediaDownload.ts +++ b/app/lib/methods/handleMediaDownload.ts @@ -1,8 +1,8 @@ import * as FileSystem from 'expo-file-system/legacy'; import * as mime from 'react-native-mime-types'; import { isEmpty } from 'lodash'; -import { type Model } from '@nozbe/watermelondb'; +import { type Model } from '../database/facade'; import { type IAttachment, type TAttachmentEncryption, type TMessageModel } from '../../definitions'; import { sanitizeLikeString } from '../database/utils'; import { store } from '../store/auxStore'; diff --git a/app/lib/methods/helpers/findSubscriptionsRooms.ts b/app/lib/methods/helpers/findSubscriptionsRooms.ts index 7eaaecfcd9c..2c8a35c6848 100644 --- a/app/lib/methods/helpers/findSubscriptionsRooms.ts +++ b/app/lib/methods/helpers/findSubscriptionsRooms.ts @@ -1,5 +1,4 @@ -import { Q } from '@nozbe/watermelondb'; - +import { Q } from '../../database/facade'; import { type IServerSubscription, type IServerRoom } from '../../../definitions'; import database from '../../database'; diff --git a/app/lib/methods/helpers/markMessagesRead.ts b/app/lib/methods/helpers/markMessagesRead.ts index ea79d27a156..6e7e6e510e8 100644 --- a/app/lib/methods/helpers/markMessagesRead.ts +++ b/app/lib/methods/helpers/markMessagesRead.ts @@ -1,5 +1,4 @@ -import { Q } from '@nozbe/watermelondb'; - +import { Q } from '../../database/facade'; import database from '../../database'; interface IMarkMessagesReadParams { diff --git a/app/lib/methods/loadThreadMessages.ts b/app/lib/methods/loadThreadMessages.ts index 10e568e356b..3dcbee035f9 100644 --- a/app/lib/methods/loadThreadMessages.ts +++ b/app/lib/methods/loadThreadMessages.ts @@ -1,7 +1,6 @@ -import { Q } from '@nozbe/watermelondb'; -import { sanitizedRaw } from '@nozbe/watermelondb/RawRecord'; import EJSON from 'ejson'; +import { Q, sanitizedRaw } from '../database/facade'; import database from '../database'; import log from './helpers/log'; import { Encryption } from '../encryption'; diff --git a/app/lib/methods/logout.ts b/app/lib/methods/logout.ts index d27819472d4..5feb8595a9a 100644 --- a/app/lib/methods/logout.ts +++ b/app/lib/methods/logout.ts @@ -1,6 +1,6 @@ import { Rocketchat as RocketchatClient } from '@rocket.chat/sdk'; -import type Model from '@nozbe/watermelondb/Model'; +import type { Model } from '../database/facade'; import { getDeviceToken } from '../notifications'; import { isSsl } from './helpers'; import { BASIC_AUTH_KEY } from './helpers/fetch'; @@ -53,7 +53,7 @@ function removeCurrentServer() { export async function removeServerDatabase({ server }: { server: string }): Promise { try { - const db = getDatabase(server); + const db = await getDatabase(server); await db.write(() => db.unsafeResetDatabase()); } catch (e) { log(e); diff --git a/app/lib/methods/search.ts b/app/lib/methods/search.ts index 468c8e52020..2c94b6ccf45 100644 --- a/app/lib/methods/search.ts +++ b/app/lib/methods/search.ts @@ -1,5 +1,4 @@ -import { Q } from '@nozbe/watermelondb'; - +import { Q } from '../database/facade'; import { getSubscriptionSearchClause, sanitizeLikeString } from '../database/utils'; import database from '../database/index'; import { store as reduxStore } from '../store/auxStore'; diff --git a/app/lib/methods/sendFileMessage/sendFileMessage.ts b/app/lib/methods/sendFileMessage/sendFileMessage.ts index 8caa56452bb..eb88c6d1d1b 100644 --- a/app/lib/methods/sendFileMessage/sendFileMessage.ts +++ b/app/lib/methods/sendFileMessage/sendFileMessage.ts @@ -1,7 +1,7 @@ -import { sanitizedRaw } from '@nozbe/watermelondb/RawRecord'; import { settings as RocketChatSettings } from '@rocket.chat/sdk'; import { Alert } from 'react-native'; +import { sanitizedRaw } from '../../database/facade'; import { type IUser, type TSendFileMessageFileInfo, type TUploadModel } from '../../../definitions'; import i18n from '../../../i18n'; import database from '../../database'; diff --git a/app/lib/methods/sendFileMessage/utils.ts b/app/lib/methods/sendFileMessage/utils.ts index 71446326bc6..ddcc82af226 100644 --- a/app/lib/methods/sendFileMessage/utils.ts +++ b/app/lib/methods/sendFileMessage/utils.ts @@ -1,8 +1,8 @@ -import { sanitizedRaw } from '@nozbe/watermelondb/RawRecord'; import isEmpty from 'lodash/isEmpty'; import { Alert } from 'react-native'; import * as FileSystem from 'expo-file-system/legacy'; +import { sanitizedRaw } from '../../database/facade'; import { getUploadByPath } from '../../database/services/Upload'; import { type IUpload, type TUploadModel } from '../../../definitions'; import i18n from '../../../i18n'; diff --git a/app/lib/methods/sendMessage.ts b/app/lib/methods/sendMessage.ts index c7f4942f0db..13b1283b436 100644 --- a/app/lib/methods/sendMessage.ts +++ b/app/lib/methods/sendMessage.ts @@ -1,6 +1,5 @@ -import { sanitizedRaw } from '@nozbe/watermelondb/RawRecord'; -import { type Model } from '@nozbe/watermelondb'; - +import { sanitizedRaw } from '../database/facade'; +import { type Model } from '../database/facade'; import database from '../database'; import log from './helpers/log'; import { random } from './helpers'; diff --git a/app/lib/methods/subscriptions/room.ts b/app/lib/methods/subscriptions/room.ts index a0ce1310b24..fe08c427168 100644 --- a/app/lib/methods/subscriptions/room.ts +++ b/app/lib/methods/subscriptions/room.ts @@ -1,8 +1,7 @@ import EJSON from 'ejson'; -import { sanitizedRaw } from '@nozbe/watermelondb/RawRecord'; import { InteractionManager } from 'react-native'; -import { Q } from '@nozbe/watermelondb'; +import { sanitizedRaw, Q } from '../../database/facade'; import log from '../helpers/log'; import protectedFunction from '../helpers/protectedFunction'; import buildMessage from '../helpers/buildMessage'; diff --git a/app/lib/methods/subscriptions/rooms.ts b/app/lib/methods/subscriptions/rooms.ts index 78aeb9ca674..9851c8329a4 100644 --- a/app/lib/methods/subscriptions/rooms.ts +++ b/app/lib/methods/subscriptions/rooms.ts @@ -1,8 +1,8 @@ -import { sanitizedRaw } from '@nozbe/watermelondb/RawRecord'; import { InteractionManager } from 'react-native'; import EJSON from 'ejson'; -import type Model from '@nozbe/watermelondb/Model'; +import { sanitizedRaw } from '../../database/facade'; +import type { Model } from '../../database/facade'; import database from '../../database'; import protectedFunction from '../helpers/protectedFunction'; import log from '../helpers/log'; diff --git a/app/lib/methods/updateMessages.ts b/app/lib/methods/updateMessages.ts index 82ff479d5f1..52a2befae00 100644 --- a/app/lib/methods/updateMessages.ts +++ b/app/lib/methods/updateMessages.ts @@ -1,6 +1,4 @@ -import { type Model, Q } from '@nozbe/watermelondb'; -import { sanitizedRaw } from '@nozbe/watermelondb/RawRecord'; - +import { type Model, Q, sanitizedRaw } from '../database/facade'; import { MESSAGE_TYPE_ANY_LOAD } from '../constants/messageTypeLoad'; import { type IMessage, diff --git a/app/lib/services/connect.ts b/app/lib/services/connect.ts index 12348aeaf98..0f6a4bf5714 100644 --- a/app/lib/services/connect.ts +++ b/app/lib/services/connect.ts @@ -1,8 +1,7 @@ import { Rocketchat as RocketchatClient } from '@rocket.chat/sdk'; -import { sanitizedRaw } from '@nozbe/watermelondb/RawRecord'; import { InteractionManager } from 'react-native'; -import { Q } from '@nozbe/watermelondb'; +import { sanitizedRaw, Q } from '../database/facade'; import log from '../methods/helpers/log'; import { setActiveUsers } from '../../actions/activeUsers'; import protectedFunction from '../methods/helpers/protectedFunction'; @@ -49,14 +48,14 @@ let rolesListener: any; let notifyLoggedListener: any; let logoutListener: any; -function connect({ server, logoutOnError = false }: { server: string; logoutOnError?: boolean }): Promise { - return new Promise(resolve => { - // Check for running requests and abort them before connecting to the server - abort(); - - disconnect(); - database.setActiveDB(server); +async function connect({ server, logoutOnError = false }: { server: string; logoutOnError?: boolean }): Promise { + // Check for running requests and abort them before connecting to the server + abort(); + disconnect(); + // Active DB must be open before any stream listener reads database.active. + await database.setActiveDB(server); + return new Promise(resolve => { store.dispatch(connectRequest()); if (connectingListener) { diff --git a/app/sagas/createChannel.js b/app/sagas/createChannel.js index 2916653cfb4..c55049bb846 100644 --- a/app/sagas/createChannel.js +++ b/app/sagas/createChannel.js @@ -1,5 +1,5 @@ import { call, put, select, take, takeLatest } from 'redux-saga/effects'; -import { sanitizedRaw } from '@nozbe/watermelondb/RawRecord'; +import { sanitizedRaw } from '../lib/database/facade'; import { CREATE_CHANNEL, LOGIN } from '../actions/actionsTypes'; import { createChannelFailure, createChannelSuccess } from '../actions/createChannel'; diff --git a/app/sagas/createDiscussion.js b/app/sagas/createDiscussion.js index 526b3b29828..f6e4089ea82 100644 --- a/app/sagas/createDiscussion.js +++ b/app/sagas/createDiscussion.js @@ -1,5 +1,5 @@ import { call, put, select, take, takeLatest } from 'redux-saga/effects'; -import { sanitizedRaw } from '@nozbe/watermelondb/RawRecord'; +import { sanitizedRaw } from '../lib/database/facade'; import { CREATE_DISCUSSION, LOGIN } from '../actions/actionsTypes'; import { createDiscussionFailure, createDiscussionSuccess } from '../actions/createDiscussion'; diff --git a/app/sagas/init.js b/app/sagas/init.js index d9d6024abe8..223e663be36 100644 --- a/app/sagas/init.js +++ b/app/sagas/init.js @@ -15,6 +15,7 @@ import { RootEnum } from '../definitions'; import { getSortPreferences } from '../lib/methods/userPreferencesMethods'; import { deepLinkingClickCallPush } from '../actions/deepLinking'; import { getServerById } from '../lib/database/services/Server'; +import { runMigrationIfNeeded } from '../lib/database/migration/orchestrator'; export const initLocalSettings = function* initLocalSettings() { const sortPreferences = getSortPreferences(); @@ -23,6 +24,21 @@ export const initLocalSettings = function* initLocalSettings() { const restore = function* restore() { try { + // Open the global servers database first: this installs the native key store and opens the + // encrypted servers DB. The migration below opens these DBs to port into them and must use + // the real key store, not the dev shim — opening before it is installed keys the file with an + // ephemeral key and fails ("file is not a database") on the next boot. Idempotent once opened. + yield call(database.initServers); + + // Port client-only data into the encrypted DB before any server data is read or re-auth + // is evaluated. Runs while the bootsplash is still up. A failure must not block boot or log + // the user out: the state machine is crash-safe and resumes on the next launch. + try { + yield call(runMigrationIfNeeded); + } catch (e) { + log(e); + } + const server = UserPreferences.getString(CURRENT_SERVER); let userId = UserPreferences.getString(`${TOKEN_KEY}-${server}`); diff --git a/app/sagas/login.js b/app/sagas/login.js index a1eeaec0520..003cc92ced0 100644 --- a/app/sagas/login.js +++ b/app/sagas/login.js @@ -1,6 +1,5 @@ import { call, cancel, delay, fork, put, race, select, spawn, take, takeLatest } from 'redux-saga/effects'; -import { sanitizedRaw } from '@nozbe/watermelondb/RawRecord'; -import { Q } from '@nozbe/watermelondb'; +import { sanitizedRaw, Q } from '../lib/database/facade'; import dayjs from '../lib/dayjs'; import * as types from '../actions/actionsTypes'; diff --git a/app/sagas/messages.js b/app/sagas/messages.js index 352f1e3d761..bd801747a5e 100644 --- a/app/sagas/messages.js +++ b/app/sagas/messages.js @@ -1,5 +1,5 @@ import { select, takeLatest } from 'redux-saga/effects'; -import { Q } from '@nozbe/watermelondb'; +import { Q } from '../lib/database/facade'; import { MESSAGES } from '../actions/actionsTypes'; import database from '../lib/database'; diff --git a/app/sagas/rooms.js b/app/sagas/rooms.js index 174325121f7..8a6126bd820 100644 --- a/app/sagas/rooms.js +++ b/app/sagas/rooms.js @@ -1,6 +1,5 @@ import { cancel, delay, fork, put, race, select, take } from 'redux-saga/effects'; -import { Q } from '@nozbe/watermelondb'; -import { sanitizedRaw } from '@nozbe/watermelondb/RawRecord'; +import { Q, sanitizedRaw } from '../lib/database/facade'; import * as types from '../actions/actionsTypes'; import { roomsFailure, roomsRefresh, roomsSuccess } from '../actions/rooms'; diff --git a/app/sagas/selectServer.ts b/app/sagas/selectServer.ts index 5373f6b0fcf..570f4b8d0d2 100644 --- a/app/sagas/selectServer.ts +++ b/app/sagas/selectServer.ts @@ -1,10 +1,9 @@ import { put, takeLatest } from 'redux-saga/effects'; -import { sanitizedRaw } from '@nozbe/watermelondb/RawRecord'; -import { Q } from '@nozbe/watermelondb'; import valid from 'semver/functions/valid'; import coerce from 'semver/functions/coerce'; import { call } from 'typed-redux-saga'; +import { Q, sanitizedRaw } from '../lib/database/facade'; import Navigation from '../lib/navigation/appNavigation'; import { SERVER } from '../actions/actionsTypes'; import { diff --git a/app/views/AddExistingChannelView/index.tsx b/app/views/AddExistingChannelView/index.tsx index 4ad9fd4d66e..d3b0e1776b5 100644 --- a/app/views/AddExistingChannelView/index.tsx +++ b/app/views/AddExistingChannelView/index.tsx @@ -2,8 +2,8 @@ import { useEffect, useLayoutEffect, useState } from 'react'; import { type NativeStackNavigationOptions, type NativeStackNavigationProp } from '@react-navigation/native-stack'; import { type RouteProp, useNavigation, useRoute } from '@react-navigation/native'; import { FlatList } from 'react-native'; -import { Q } from '@nozbe/watermelondb'; +import { Q } from '../../lib/database/facade'; import { textInputDebounceTime } from '../../lib/constants/debounceConfig'; import * as List from '../../containers/List'; import database from '../../lib/database'; diff --git a/app/views/NewMessageView/index.tsx b/app/views/NewMessageView/index.tsx index 9ac3c4d0935..f4cc56bbd36 100644 --- a/app/views/NewMessageView/index.tsx +++ b/app/views/NewMessageView/index.tsx @@ -1,10 +1,10 @@ -import { Q } from '@nozbe/watermelondb'; import { type NativeStackNavigationProp } from '@react-navigation/native-stack'; import { useCallback, useEffect, useLayoutEffect, useState } from 'react'; import { FlatList } from 'react-native'; import { shallowEqual } from 'react-redux'; import { useNavigation } from '@react-navigation/native'; +import { Q } from '../../lib/database/facade'; import * as HeaderButton from '../../containers/Header/components/HeaderButton'; import * as List from '../../containers/List'; import SafeAreaView from '../../containers/SafeAreaView'; diff --git a/app/views/NewServerView/hooks/useServersHistory.tsx b/app/views/NewServerView/hooks/useServersHistory.tsx index bdbb1cb4273..717737e0cdd 100644 --- a/app/views/NewServerView/hooks/useServersHistory.tsx +++ b/app/views/NewServerView/hooks/useServersHistory.tsx @@ -1,6 +1,6 @@ import { useEffect, useState } from 'react'; -import { Q } from '@nozbe/watermelondb'; +import { Q } from '../../../lib/database/facade'; import { textInputDebounceTime } from '../../../lib/constants/debounceConfig'; import { useDebounce } from '../../../lib/methods/helpers'; import { sanitizeLikeString } from '../../../lib/database/utils'; diff --git a/app/views/RoomActionsView/index.tsx b/app/views/RoomActionsView/index.tsx index ffe0e87e3f5..053f145b9f1 100644 --- a/app/views/RoomActionsView/index.tsx +++ b/app/views/RoomActionsView/index.tsx @@ -1,5 +1,4 @@ /* eslint-disable complexity */ -import { Q } from '@nozbe/watermelondb'; import { type NativeStackNavigationOptions, type NativeStackNavigationProp } from '@react-navigation/native-stack'; import isEmpty from 'lodash/isEmpty'; import { Share, Text, View } from 'react-native'; @@ -8,6 +7,7 @@ import { type Observable, type Subscription } from 'rxjs'; import { type CompositeNavigationProp } from '@react-navigation/native'; import { Component } from 'react'; +import { Q } from '../../lib/database/facade'; import { leaveRoom } from '../../actions/room'; import Avatar from '../../containers/Avatar'; import * as HeaderButton from '../../containers/Header/components/HeaderButton'; diff --git a/app/views/RoomInfoEditView/hooks/useRoomDeletionActions.tsx b/app/views/RoomInfoEditView/hooks/useRoomDeletionActions.tsx index effa373ea6f..c9ff5ba63de 100644 --- a/app/views/RoomInfoEditView/hooks/useRoomDeletionActions.tsx +++ b/app/views/RoomInfoEditView/hooks/useRoomDeletionActions.tsx @@ -1,8 +1,8 @@ -import { Q } from '@nozbe/watermelondb'; import { Alert } from 'react-native'; import { useDispatch } from 'react-redux'; import { type NativeStackNavigationProp } from '@react-navigation/native-stack'; +import { Q } from '../../../lib/database/facade'; import { type ChatsStackParamList } from '../../../stacks/types'; import { type ModalStackParamList } from '../../../stacks/MasterDetailStack/types'; import { type TNavigation } from '../../../stacks/stackType'; diff --git a/app/views/RoomMembersView/helpers.ts b/app/views/RoomMembersView/helpers.ts index 80bd68e6bd3..0566132aa08 100644 --- a/app/views/RoomMembersView/helpers.ts +++ b/app/views/RoomMembersView/helpers.ts @@ -1,6 +1,6 @@ -import { Q } from '@nozbe/watermelondb'; import { Alert } from 'react-native'; +import { Q } from '../../lib/database/facade'; import { LISTENER } from '../../containers/Toast'; import { type IGetRoomRoles, type IUser, SubscriptionType, type TSubscriptionModel, type TUserModel } from '../../definitions'; import I18n from '../../i18n'; diff --git a/app/views/RoomView/List/hooks/buildVisibleSystemTypesClause.ts b/app/views/RoomView/List/hooks/buildVisibleSystemTypesClause.ts index 4d20c4235bc..ec64437640a 100644 --- a/app/views/RoomView/List/hooks/buildVisibleSystemTypesClause.ts +++ b/app/views/RoomView/List/hooks/buildVisibleSystemTypesClause.ts @@ -1,5 +1,4 @@ -import { Q } from '@nozbe/watermelondb'; - +import { Q } from '../../../../lib/database/facade'; import { MESSAGE_TYPE_ANY_LOAD } from '../../../../lib/constants/messageTypeLoad'; /** diff --git a/app/views/RoomView/List/hooks/useMessages.ts b/app/views/RoomView/List/hooks/useMessages.ts index 528c0bdb533..b553fcde775 100644 --- a/app/views/RoomView/List/hooks/useMessages.ts +++ b/app/views/RoomView/List/hooks/useMessages.ts @@ -1,8 +1,8 @@ import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'; -import { Q } from '@nozbe/watermelondb'; import { type Subscription } from 'rxjs'; import { useDispatch, useStore } from 'react-redux'; +import { Q } from '../../../../lib/database/facade'; import { type IApplicationState, type RoomType, type TAnyMessageModel } from '../../../../definitions'; import database from '../../../../lib/database'; import { getMessageById } from '../../../../lib/database/services/Message'; diff --git a/app/views/RoomView/UploadProgress.tsx b/app/views/RoomView/UploadProgress.tsx index 696f39f2e3b..f79c4afc3c3 100644 --- a/app/views/RoomView/UploadProgress.tsx +++ b/app/views/RoomView/UploadProgress.tsx @@ -1,9 +1,9 @@ import { Component } from 'react'; import { ScrollView, StyleSheet, Text, TouchableOpacity, View } from 'react-native'; -import { Q } from '@nozbe/watermelondb'; import { type Observable, type Subscription } from 'rxjs'; import { A11y } from 'react-native-a11y-order'; +import { Q } from '../../lib/database/facade'; import database from '../../lib/database'; import log from '../../lib/methods/helpers/log'; import I18n from '../../i18n'; diff --git a/app/views/RoomView/index.tsx b/app/views/RoomView/index.tsx index 2f0fc05691a..1d570cadd10 100644 --- a/app/views/RoomView/index.tsx +++ b/app/views/RoomView/index.tsx @@ -2,7 +2,6 @@ import { Component, createRef, type RefObject } from 'react'; import { AccessibilityInfo, InteractionManager, PixelRatio, Text, View } from 'react-native'; import { connect } from 'react-redux'; import parse from 'url-parse'; -import { Q } from '@nozbe/watermelondb'; import { dequal } from 'dequal'; import { withSafeAreaInsets } from 'react-native-safe-area-context'; import { type Subscription } from 'rxjs'; @@ -11,6 +10,7 @@ import { type NavigatorScreenParams } from '@react-navigation/native'; import { type TNavigation } from 'stacks/stackType'; +import { Q } from '../../lib/database/facade'; import dayjs from '../../lib/dayjs'; import { getRoutingConfig, diff --git a/app/views/RoomsListView/hooks/useSubscriptions.ts b/app/views/RoomsListView/hooks/useSubscriptions.ts index 485649f35c4..1606a705014 100644 --- a/app/views/RoomsListView/hooks/useSubscriptions.ts +++ b/app/views/RoomsListView/hooks/useSubscriptions.ts @@ -1,8 +1,8 @@ -import { Q } from '@nozbe/watermelondb'; import { useEffect, useRef, useState } from 'react'; import { shallowEqual } from 'react-redux'; import type { Subscription } from 'rxjs'; +import { Q } from '../../../lib/database/facade'; import { type TSubscriptionModel } from '../../../definitions'; import { SortBy } from '../../../lib/constants/constantDisplayMode'; import database from '../../../lib/database'; diff --git a/app/views/SearchMessagesView/index.tsx b/app/views/SearchMessagesView/index.tsx index 47b9dd90f90..555b4794a9e 100644 --- a/app/views/SearchMessagesView/index.tsx +++ b/app/views/SearchMessagesView/index.tsx @@ -1,11 +1,11 @@ import { type NativeStackNavigationOptions, type NativeStackNavigationProp } from '@react-navigation/native-stack'; import { type CompositeNavigationProp, type RouteProp } from '@react-navigation/core'; import { FlatList, Text, View } from 'react-native'; -import { Q } from '@nozbe/watermelondb'; import { connect } from 'react-redux'; import { dequal } from 'dequal'; import { Component } from 'react'; +import { Q } from '../../lib/database/facade'; import { FormTextInput } from '../../containers/TextInput'; import ActivityIndicator from '../../containers/ActivityIndicator'; import Markdown from '../../containers/markdown'; diff --git a/app/views/SelectServerView.tsx b/app/views/SelectServerView.tsx index f296f88fe02..0085c96db70 100644 --- a/app/views/SelectServerView.tsx +++ b/app/views/SelectServerView.tsx @@ -1,10 +1,10 @@ import { useEffect, useLayoutEffect, useState } from 'react'; import { FlatList } from 'react-native'; import { type NativeStackNavigationProp } from '@react-navigation/native-stack'; -import { Q } from '@nozbe/watermelondb'; import { useNavigation } from '@react-navigation/native'; import { useDispatch } from 'react-redux'; +import { Q } from '../lib/database/facade'; import I18n from '../i18n'; import ServerItem, { ROW_HEIGHT } from '../containers/ServerItem'; import database from '../lib/database'; diff --git a/app/views/SelectedUsersView/index.tsx b/app/views/SelectedUsersView/index.tsx index 7f0f785cc4e..50ed76e54a2 100644 --- a/app/views/SelectedUsersView/index.tsx +++ b/app/views/SelectedUsersView/index.tsx @@ -1,4 +1,3 @@ -import { Q } from '@nozbe/watermelondb'; import orderBy from 'lodash/orderBy'; import { useCallback, useEffect, useLayoutEffect, useState } from 'react'; import { FlatList } from 'react-native'; @@ -7,6 +6,7 @@ import { type Subscription } from 'rxjs'; import { type RouteProp, useNavigation, useRoute } from '@react-navigation/native'; import { type NativeStackNavigationProp } from '@react-navigation/native-stack'; +import { Q } from '../../lib/database/facade'; import { addUser, removeUser, reset } from '../../actions/selectedUsers'; import * as HeaderButton from '../../containers/Header/components/HeaderButton'; import * as List from '../../containers/List'; diff --git a/app/views/ShareListView/index.tsx b/app/views/ShareListView/index.tsx index f4c476b2da0..acfafbee161 100644 --- a/app/views/ShareListView/index.tsx +++ b/app/views/ShareListView/index.tsx @@ -5,9 +5,9 @@ import * as FileSystem from 'expo-file-system/legacy'; import { connect } from 'react-redux'; import * as mime from 'react-native-mime-types'; import { dequal } from 'dequal'; -import { Q } from '@nozbe/watermelondb'; import { Component } from 'react'; +import { Q } from '../../lib/database/facade'; import database from '../../lib/database'; import I18n from '../../i18n'; import DirectoryItem, { ROW_HEIGHT } from '../../containers/DirectoryItem'; diff --git a/app/views/ShareView/index.tsx b/app/views/ShareView/index.tsx index f6cf1436956..5cd749b9281 100644 --- a/app/views/ShareView/index.tsx +++ b/app/views/ShareView/index.tsx @@ -3,9 +3,9 @@ import { type NativeStackNavigationOptions, type NativeStackNavigationProp } fro import { type RouteProp } from '@react-navigation/native'; import { Keyboard, Text, View } from 'react-native'; import { connect } from 'react-redux'; -import { Q } from '@nozbe/watermelondb'; import { type Dispatch } from 'redux'; +import { Q } from '../../lib/database/facade'; import { compareServerVersion } from '../../lib/methods/helpers/compareServerVersion'; import { type IMessageComposerRef, MessageComposerContainer } from '../../containers/MessageComposer'; import { type InsideStackParamList } from '../../stacks/types'; diff --git a/app/views/TeamChannelsView.tsx b/app/views/TeamChannelsView.tsx index a0daad606ae..4af2657d5f5 100644 --- a/app/views/TeamChannelsView.tsx +++ b/app/views/TeamChannelsView.tsx @@ -1,9 +1,9 @@ -import { Q } from '@nozbe/watermelondb'; import { type NativeStackNavigationOptions } from '@react-navigation/native-stack'; import { Alert, FlatList, Keyboard, PixelRatio } from 'react-native'; import { connect } from 'react-redux'; import { Component } from 'react'; +import { Q } from '../lib/database/facade'; import { deleteRoom } from '../actions/room'; import { type DisplayMode } from '../lib/constants/constantDisplayMode'; import { textInputDebounceTime } from '../lib/constants/debounceConfig'; diff --git a/app/views/ThreadMessagesView/index.tsx b/app/views/ThreadMessagesView/index.tsx index b8b4639d9b9..d12431215ac 100644 --- a/app/views/ThreadMessagesView/index.tsx +++ b/app/views/ThreadMessagesView/index.tsx @@ -1,11 +1,10 @@ import { FlatList } from 'react-native'; import { connect } from 'react-redux'; -import { Q } from '@nozbe/watermelondb'; -import { sanitizedRaw } from '@nozbe/watermelondb/RawRecord'; import { type NativeStackNavigationOptions } from '@react-navigation/native-stack'; import { type Observable, type Subscription } from 'rxjs'; import { Component } from 'react'; +import { sanitizedRaw, Q } from '../../lib/database/facade'; import { showActionSheetRef } from '../../containers/ActionSheet'; import { CustomIcon } from '../../containers/CustomIcon'; import ActivityIndicator from '../../containers/ActivityIndicator'; diff --git a/babel.config.js b/babel.config.js index b00c79cb300..c5ccbad2d44 100644 --- a/babel.config.js +++ b/babel.config.js @@ -8,6 +8,9 @@ module.exports = { } ], ['@babel/plugin-proposal-decorators', { legacy: true }], + // Inline Drizzle migration .sql files as string literals so the migrator can run them + // (works in Metro and Jest; expo-sqlite has no native .sql loader). + ['babel-plugin-inline-import', { extensions: ['.sql'] }], '@babel/plugin-transform-named-capturing-groups-regex', ['module:react-native-dotenv'], 'react-native-worklets/plugin' diff --git a/ios/Podfile b/ios/Podfile index a71b3201cf1..66ac3240ef3 100644 --- a/ios/Podfile +++ b/ios/Podfile @@ -29,14 +29,18 @@ end abstract_target 'defaults' do all_pods + # Required by Database.swift (NSE + main app) to open SQLCipher-encrypted databases. + # Version locked to match expo-sqlite 16.0.10's vendored SQLCipher 4.7.0. + pod 'SQLCipher', '~> 4.7.0' target 'Rocket.Chat' target 'NotificationService' end -$static_framework = [ +$static_framework = [ 'WatermelonDB', - 'simdjson' + 'simdjson', + 'SQLCipher' ] pre_install do |installer| Pod::Installer::Xcode::TargetValidator.send(:define_method, :verify_no_static_framework_transitive_dependencies) {} @@ -65,7 +69,7 @@ post_install do |installer| config.build_settings['CODE_SIGNING_REQUIRED'] = "NO" config.build_settings['CODE_SIGNING_ALLOWED'] = "NO" config.build_settings['ENABLE_BITCODE'] = "NO" - + # Add SecureStorage headers to search paths for react-native-webview if target.name == 'react-native-webview' config.build_settings['HEADER_SEARCH_PATHS'] ||= ['$(inherited)'] @@ -74,6 +78,40 @@ post_install do |installer| end end + # SQLCipher's xcconfig propagates defines and header search paths to aggregate + # xcconfigs, breaking Swift module maps for the SDK's built-in SQLite3: + # - _SQLITE3_H_=1 / _FTS5_H=1 / _SQLITE3RTREE_H_=1: block SQLite3.modulemap + # - HEADER_SEARCH_PATHS into SQLCipher pod: cause sqlite3_api_routines + # struct redefinition (two different sqlite3.h in scope) + # Strip them from aggregate xcconfigs; only the SQLCipher pod itself needs them. + Dir.glob(File.join(__dir__, 'Pods/Target Support Files/**/*.xcconfig')).each do |xcconfig_path| + next unless File.basename(xcconfig_path).start_with?('Pods-') + content = File.read(xcconfig_path) + modified = content + .gsub(/ _SQLITE3_H_=1/, '') + .gsub(/ _FTS5_H=1/, '') + .gsub(/ _SQLITE3RTREE_H_=1/, '') + .gsub(/\s*"[^"]*\/SQLCipher"/, '') + .gsub(/\s+\$[({]PODS_ROOT[)}]\/SQLCipher\b/, '') + .gsub(/\s+"[^"]*\/Headers\/Public\/SQLCipher"/, '') + File.write(xcconfig_path, modified) if modified != content + end + + # The static_framework override bypasses CocoaPods' normal LIBRARY_SEARCH_PATHS + # injection for SQLCipher, so -l"SQLCipher" fails to resolve at link time. + # Add the path manually to all aggregate xcconfigs that already have the setting. + Dir.glob(File.join(__dir__, 'Pods/Target Support Files/Pods-*/*.xcconfig')).each do |xcconfig_path| + content = File.read(xcconfig_path) + sqlcipher_lib_dir = '"${PODS_CONFIGURATION_BUILD_DIR}/SQLCipher"' + if content.include?('LIBRARY_SEARCH_PATHS') && !content.include?(sqlcipher_lib_dir) + modified = content.gsub( + 'LIBRARY_SEARCH_PATHS = $(inherited)', + "LIBRARY_SEARCH_PATHS = $(inherited) #{sqlcipher_lib_dir}" + ) + File.write(xcconfig_path, modified) if modified != content + end + end + bitcode_strip_path = `xcrun --find bitcode_strip`.chop! def strip_bitcode_from_framework(bitcode_strip_path, framework_relative_path) framework_path = File.join(Dir.pwd, framework_relative_path) diff --git a/ios/Podfile.lock b/ios/Podfile.lock index a80d616f080..a94ca5fe4e8 100644 --- a/ios/Podfile.lock +++ b/ios/Podfile.lock @@ -129,6 +129,8 @@ PODS: - ReactCommon/turbomodule/core - SocketRocket - Yoga + - ExpoSQLite (16.0.10): + - ExpoModulesCore - ExpoSystemUI (6.0.9): - ExpoModulesCore - ExpoVideoThumbnails (10.0.8): @@ -3531,6 +3533,11 @@ PODS: - SDWebImage/Core (~> 5.17) - simdjson (3.9.4) - SocketRocket (0.7.1) + - SQLCipher (4.7.0): + - SQLCipher/standard (= 4.7.0) + - SQLCipher/common (4.7.0) + - SQLCipher/standard (4.7.0): + - SQLCipher/common - TOCropViewController (2.7.4) - WatermelonDB (0.28.1-0): - React @@ -3564,6 +3571,7 @@ DEPENDENCIES: - ExpoKeepAwake (from `../node_modules/expo-keep-awake/ios`) - ExpoLocalAuthentication (from `../node_modules/expo-local-authentication/ios`) - ExpoModulesCore (from `../node_modules/expo-modules-core`) + - ExpoSQLite (from `../node_modules/expo-sqlite/ios`) - ExpoSystemUI (from `../node_modules/expo-system-ui/ios`) - ExpoVideoThumbnails (from `../node_modules/expo-video-thumbnails/ios`) - ExpoWebBrowser (from `../node_modules/expo-web-browser/ios`) @@ -3676,6 +3684,7 @@ DEPENDENCIES: - RNWorklets (from `../node_modules/react-native-worklets`) - "simdjson (from `../node_modules/@nozbe/simdjson`)" - SocketRocket (~> 0.7.1) + - SQLCipher (~> 4.7.0) - "WatermelonDB (from `../node_modules/@nozbe/watermelondb`)" - Yoga (from `../node_modules/react-native/ReactCommon/yoga`) @@ -3707,6 +3716,7 @@ SPEC REPOS: - SDWebImageSVGCoder - SDWebImageWebPCoder - SocketRocket + - SQLCipher - TOCropViewController - ZXingObjC @@ -3753,6 +3763,8 @@ EXTERNAL SOURCES: :path: "../node_modules/expo-local-authentication/ios" ExpoModulesCore: :path: "../node_modules/expo-modules-core" + ExpoSQLite: + :path: "../node_modules/expo-sqlite/ios" ExpoSystemUI: :path: "../node_modules/expo-system-ui/ios" ExpoVideoThumbnails: @@ -3997,6 +4009,7 @@ SPEC CHECKSUMS: ExpoKeepAwake: 55f75eca6499bb9e4231ebad6f3e9cb8f99c0296 ExpoLocalAuthentication: 8a31808565da7af926dd9b595e98594d8b1553b6 ExpoModulesCore: 91a57f1d109cf53fe58d44fcf6b68a777561549a + ExpoSQLite: e8ec472fce715add19e5fc6e44698ab96a902cd3 ExpoSystemUI: 2ad325f361a2fcd96a464e8574e19935c461c9cc ExpoVideoThumbnails: 503a79271416c8723f04b55ea4737282513ebe4f ExpoWebBrowser: 17b064c621789e41d4816c95c93f429b84971f52 @@ -4102,7 +4115,7 @@ SPEC CHECKSUMS: React-timing: c39eeb992274aeaeb9f4666dc97a36a31d33fe94 React-utils: 2f9ba0088251788ad66aa1855ff99ed2424024d2 ReactAppDependencyProvider: 1bcd3527ac0390a1c898c114f81ff954be35ed79 - ReactCodegen: 7fadc30654a54b3a0d62df08f55e2211e25486ee + ReactCodegen: 250f0c4b774af2cc431fe53c10d01e124a4bdff1 ReactCommon: 6d0fa86a4510730da7c72560e0ced14258292ab9 ReactNativeIncallManager: dccd3e7499caa3bb73d3acfedf4fb0360f1a87d5 RNBootSplash: 7fcc9a58ae343aeb1a1dd49f9030832fe432c544 @@ -4131,11 +4144,12 @@ SPEC CHECKSUMS: SDWebImageWebPCoder: e38c0a70396191361d60c092933e22c20d5b1380 simdjson: 7bb9e33d87737cec966e7b427773c67baa4458fe SocketRocket: d4aabe649be1e368d1318fdf28a022d714d65748 + SQLCipher: ba9d0076041ed767c5bd3d3f77098318d04a403c TOCropViewController: 80b8985ad794298fb69d3341de183f33d1853654 WatermelonDB: 4c846c8cb94eef3cba90fa034d15310163226703 Yoga: 1e91d83a5286cfd3b725eade59274c92270540d4 ZXingObjC: 8898711ab495761b2dbbdec76d90164a6d7e14c5 -PODFILE CHECKSUM: d05b9b865205e9c845b4e7c288577d4b4ae403ab +PODFILE CHECKSUM: 24fecc60b74227f0945a090fd455b87c06a52800 COCOAPODS: 1.15.2 diff --git a/ios/RocketChatRN-Bridging-Header.h b/ios/RocketChatRN-Bridging-Header.h index 617367b1961..b90216642e3 100644 --- a/ios/RocketChatRN-Bridging-Header.h +++ b/ios/RocketChatRN-Bridging-Header.h @@ -3,6 +3,7 @@ // #import +#import #import "Libraries/SecureStorage.h" #import "Libraries/MMKVKeyManager.h" #import "Shared/RocketChat/MMKVBridge.h" diff --git a/ios/RocketChatRN.xcodeproj/project.pbxproj b/ios/RocketChatRN.xcodeproj/project.pbxproj index 42a9e120b1a..af093f101f6 100644 --- a/ios/RocketChatRN.xcodeproj/project.pbxproj +++ b/ios/RocketChatRN.xcodeproj/project.pbxproj @@ -214,6 +214,9 @@ 7AC0A1E02C000011001CAA11 /* Challenge.mm in Sources */ = {isa = PBXBuildFile; fileRef = 7AC0A1E02C000002001CAA11 /* Challenge.mm */; }; 7AC0A1E02C000012001CAA11 /* Challenge.mm in Sources */ = {isa = PBXBuildFile; fileRef = 7AC0A1E02C000002001CAA11 /* Challenge.mm */; }; 7ACFE7D92DDE48760090D9BC /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7ACFE7D82DDE48760090D9BC /* AppDelegate.swift */; }; + 7ADBC0032F80000100000003 /* DatabaseKeyStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7ADBC0012F80000100000001 /* DatabaseKeyStore.swift */; }; + 7ADBC0042F80000100000004 /* DatabaseKeyStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7ADBC0012F80000100000001 /* DatabaseKeyStore.swift */; }; + 7ADBC0062F80000100000006 /* DatabaseKeyStore.mm in Sources */ = {isa = PBXBuildFile; fileRef = 7ADBC0022F80000100000002 /* DatabaseKeyStore.mm */; }; 7AE10C0828A59530003593CB /* Inter.ttf in Resources */ = {isa = PBXBuildFile; fileRef = 7AE10C0528A59530003593CB /* Inter.ttf */; }; 7AVR00022F5F5900002A6BDE /* VoipRegion.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7AVR00002F5F5900002A6BDE /* VoipRegion.swift */; }; 85160EB6C143E0493FE5F014 /* ExpoModulesProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 194D9A8897F4A486C2C6F89A /* ExpoModulesProvider.swift */; }; @@ -455,6 +458,8 @@ 7AC0A1E02C000002001CAA11 /* Challenge.mm */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.cpp.objcpp; path = Challenge.mm; sourceTree = ""; }; 7ACD4853222860DE00442C55 /* JavaScriptCore.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = JavaScriptCore.framework; path = System/Library/Frameworks/JavaScriptCore.framework; sourceTree = SDKROOT; }; 7ACFE7D82DDE48760090D9BC /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; + 7ADBC0012F80000100000001 /* DatabaseKeyStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DatabaseKeyStore.swift; sourceTree = ""; }; + 7ADBC0022F80000100000002 /* DatabaseKeyStore.mm */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.cpp.objcpp; path = DatabaseKeyStore.mm; sourceTree = ""; }; 7AE10C0528A59530003593CB /* Inter.ttf */ = {isa = PBXFileReference; lastKnownFileType = file; path = Inter.ttf; sourceTree = ""; }; 7AVR00002F5F5900002A6BDE /* VoipRegion.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VoipRegion.swift; sourceTree = ""; }; 83F98EE33D91A93DF8E69F34 /* MediaCallsAnswerRequest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MediaCallsAnswerRequest.swift; sourceTree = ""; }; @@ -953,6 +958,8 @@ 7AC0A1E02C000001001CAA11 /* Challenge.h */, 7AC0A1E02C000002001CAA11 /* Challenge.mm */, 9BE2F3DC02A264F204E3EDE3 /* DDPClient.swift */, + 7ADBC0012F80000100000001 /* DatabaseKeyStore.swift */, + 7ADBC0022F80000100000002 /* DatabaseKeyStore.mm */, ); path = Libraries; sourceTree = ""; @@ -1303,6 +1310,7 @@ "${PODS_CONFIGURATION_BUILD_DIR}/React-Core/React-Core_privacy.bundle", "${PODS_CONFIGURATION_BUILD_DIR}/React-cxxreact/React-cxxreact_privacy.bundle", "${PODS_CONFIGURATION_BUILD_DIR}/SDWebImage/SDWebImage.bundle", + "${PODS_CONFIGURATION_BUILD_DIR}/SQLCipher/SQLCipher.bundle", "${PODS_CONFIGURATION_BUILD_DIR}/TOCropViewController/TOCropViewControllerBundle.bundle", "${PODS_CONFIGURATION_BUILD_DIR}/boost/boost_privacy.bundle", "${PODS_CONFIGURATION_BUILD_DIR}/glog/glog_privacy.bundle", @@ -1337,6 +1345,7 @@ "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/React-Core_privacy.bundle", "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/React-cxxreact_privacy.bundle", "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/SDWebImage.bundle", + "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/SQLCipher.bundle", "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/TOCropViewControllerBundle.bundle", "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/boost_privacy.bundle", "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/glog_privacy.bundle", @@ -1460,6 +1469,7 @@ "${PODS_CONFIGURATION_BUILD_DIR}/React-Core/React-Core_privacy.bundle", "${PODS_CONFIGURATION_BUILD_DIR}/React-cxxreact/React-cxxreact_privacy.bundle", "${PODS_CONFIGURATION_BUILD_DIR}/SDWebImage/SDWebImage.bundle", + "${PODS_CONFIGURATION_BUILD_DIR}/SQLCipher/SQLCipher.bundle", "${PODS_CONFIGURATION_BUILD_DIR}/TOCropViewController/TOCropViewControllerBundle.bundle", "${PODS_CONFIGURATION_BUILD_DIR}/boost/boost_privacy.bundle", "${PODS_CONFIGURATION_BUILD_DIR}/glog/glog_privacy.bundle", @@ -1494,6 +1504,7 @@ "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/React-Core_privacy.bundle", "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/React-cxxreact_privacy.bundle", "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/SDWebImage.bundle", + "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/SQLCipher.bundle", "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/TOCropViewControllerBundle.bundle", "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/boost_privacy.bundle", "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/glog_privacy.bundle", diff --git a/ios/Shared/RocketChat/Database.swift b/ios/Shared/RocketChat/Database.swift index de09c70f462..d4af7ac3cd7 100644 --- a/ios/Shared/RocketChat/Database.swift +++ b/ios/Shared/RocketChat/Database.swift @@ -2,125 +2,205 @@ // Database.swift // NotificationService // -// Created by Djorkaeff Alexandre Vilela Pereira on 9/14/20. -// Copyright © 2020 Rocket.Chat. All rights reserved. +// Opens SQLCipher-encrypted databases created by the JS expo-sqlite/Drizzle driver. // +// Open sequence (invariants — do not reorder): +// 1. sqlite3_open → raw handle +// 2. PRAGMA key = "x'<64 hex>'" ← raw-key form; must be the FIRST statement. +// The x'...' prefix tells SQLCipher to use the bytes directly, skipping PBKDF2. +// Using PBKDF2 (or omitting x'...') produces "file is not a database". +// 3. PRAGMA cipher_plaintext_header_size = 32 ← the JS driver exposes a 32-byte +// plaintext header for the iOS 0xdead10cc WAL exemption; the reader must match +// or SQLCipher tries to decrypt the header and fails. +// 4. PRAGMA cipher_salt = "x'<32 hex>'" ← with a plaintext header SQLCipher does +// not store the salt in the file; it must be supplied from the keychain entry +// written by the JS keyService (key "db_salt_v1:"). Missing salt = fail. +// 5. PRAGMA busy_timeout = 500 ← mandatory for multi-process WAL safety. +// Without it the NSE starves on SQLITE_BUSY when the main app holds a WAL lock. +// 6. Verify with a trivial sqlite_master read — surfaces a wrong key immediately. +// +// ARC ownership: the sqlite3 handle is owned by this class and closed in deinit. +// Never expose the raw OpaquePointer outside this class — it becomes invalid after +// deinit, causing SQLITE_MISUSE in any pending caller. import Foundation import SQLite3 class Database { - var db: OpaquePointer? - - init(name: String) { - if let dbPath = self.getDatabasePath(name: name) { - openDatabase(databasePath: dbPath) - } else { - print("Could not resolve database path for name: \(name)") - } - } - - init(server: String) { - let domain = URL(string: server)?.domain ?? "" - if let dbPath = self.getDatabasePath(name: domain) { - openDatabase(databasePath: dbPath) - } else { - print("Could not resolve database path for server: \(server)") - } - } - - func getDatabasePath(name: String) -> String? { - let groupDir = FileManager.default.groupDir() - guard !groupDir.isEmpty else { return nil } - return "\(groupDir)/\(name).db" - } - - func openDatabase(databasePath: String) { - if sqlite3_open(databasePath, &db) == SQLITE_OK { - print("Successfully opened database at \(databasePath)") - } else { - print("Unable to open database.") - } - } - - func closeDatabase() { - if sqlite3_close(db) != SQLITE_OK { - print("Error closing database") - } else { - print("Database closed successfully") - } - db = nil - } - - deinit { - closeDatabase() - } - - func query(_ query: String, args: [String] = []) -> [[String: Any]]? { - var statement: OpaquePointer? - var results: [[String: Any]] = [] - - if sqlite3_prepare_v2(db, query, -1, &statement, nil) == SQLITE_OK { - for (index, arg) in args.enumerated() { - sqlite3_bind_text(statement, Int32(index + 1), arg, -1, unsafeBitCast(-1, to: sqlite3_destructor_type.self)) - } - - while sqlite3_step(statement) == SQLITE_ROW { - var row: [String: Any] = [:] - for columnIndex in 0..(_ result: [[String: Any]]) -> [T]? { - do { - let jsonData = try JSONSerialization.data(withJSONObject: result, options: []) - let decodedObjects = try JSONDecoder().decode([T].self, from: jsonData) - return decodedObjects - } catch { - print("Failed to decode result: \(error)") - return nil - } - } - - func readRoomEncryptionKey(for roomId: String) -> String? { - let query = "SELECT e2e_key FROM subscriptions WHERE rid = ? LIMIT 1" - if let results = self.query(query, args: [roomId]), let firstResult = results.first { - return firstResult["e2e_key"] as? String - } - return nil - } - - func readRoomEncrypted(for roomId: String) -> Bool { - let query = "SELECT encrypted FROM subscriptions WHERE rid = ? LIMIT 1" - if let results = self.query(query, args: [roomId]), let firstResult = results.first { - if let encrypted = firstResult["encrypted"] as? NSNumber { - return encrypted.boolValue - } - } - return false - } + + // MARK: - Private state (handle is not exposed to callers) + + private var db: OpaquePointer? + + // MARK: - Initialisers + + /// Opens the per-server database. + /// Derives the filename from serverUrl exactly as the JS driver does: + /// strip trailing slashes → strip scheme → replace '/' with '_' → append ".db" + init(server: String) { + open(dbName: Database.deriveServerDbName(from: server)) + } + + /// Opens a database by bare name (e.g. "default.db" for the global DB). + init(name: String) { + open(dbName: name) + } + + deinit { + if db != nil { + sqlite3_close(db) + db = nil + } + } + + // MARK: - Filename derivation (mirrors JS `deriveServerDbName` in connection.ts) + + static func deriveServerDbName(from serverUrl: String) -> String { + var s = serverUrl + while s.hasSuffix("/") { s = String(s.dropLast()) } + if let r = s.range(of: "://") { + s = String(s[r.upperBound...]) + } else if s.hasPrefix("//") { + s = String(s.dropFirst(2)) + } + // Replace interior slashes with underscores — matches JS deriveServerDbName + s = s.replacingOccurrences(of: "/", with: "_") + return s + ".db" + } + + // MARK: - Open helpers + + private func open(dbName: String) { + guard let groupRoot = FileManager.default.containerURL( + forSecurityApplicationGroupIdentifier: "group.ios.chat.rocket" + )?.path else { + NSLog("[Database] App Group container unavailable — cannot open %@", dbName) + return + } + // New encrypted DBs live in a `SQLite/` subdirectory, isolated from the legacy + // plaintext WatermelonDB files at the container root. Must stay in lockstep with + // `DB_SUBDIRECTORY` / `resolveDbDirectory()` in the JS driver (connection.ts). + let sqliteDir = (groupRoot as NSString).appendingPathComponent("SQLite") + let path = (sqliteDir as NSString).appendingPathComponent(dbName) + + guard sqlite3_open(path, &db) == SQLITE_OK else { + NSLog("[Database] sqlite3_open failed for %@: %@", dbName, String(cString: sqlite3_errmsg(db))) + sqlite3_close(db) + db = nil + return + } + + // Read the key using the storage key that matches the JS KEY_PREFIX ("db_key_v1:") + let storageKey = "db_key_v1:\(dbName)" + var readErr: NSError? + let keyHexOpt = DatabaseKeyStore.read(account: storageKey, error: &readErr) + guard readErr == nil, let keyHex = keyHexOpt else { + NSLog("[Database] No encryption key for %@ (missing or unreadable) — closing", dbName) + sqlite3_close(db) + db = nil + return + } + + // 2. Raw-key PRAGMA — must precede any schema access + let keyPragma = "PRAGMA key = \"x'\(keyHex)'\";" + guard sqlite3_exec(db, keyPragma, nil, nil, nil) == SQLITE_OK else { + NSLog("[Database] PRAGMA key failed for %@: %@", dbName, String(cString: sqlite3_errmsg(db))) + sqlite3_close(db) + db = nil + return + } + + // 3. cipher_plaintext_header_size — must match JS driver (32 bytes); without it + // SQLCipher tries to decrypt the header and fails to open the database. + sqlite3_exec(db, "PRAGMA cipher_plaintext_header_size = 32;", nil, nil, nil) + + // 4. cipher_salt — the JS driver stores the salt externally because the plaintext + // header means SQLCipher no longer embeds it in the file. Read from keychain + // key "db_salt_v1:" (the same entry the JS keyService writes). + let saltStorageKey = "db_salt_v1:\(dbName)" + var saltErr: NSError? + let saltHexOpt = DatabaseKeyStore.read(account: saltStorageKey, error: &saltErr) + guard saltErr == nil, let saltHex = saltHexOpt else { + NSLog("[Database] No cipher salt for %@ (missing or unreadable) — closing", dbName) + sqlite3_close(db) + db = nil + return + } + let saltPragma = "PRAGMA cipher_salt = \"x'\(saltHex)'\";" + sqlite3_exec(db, saltPragma, nil, nil, nil) + + // 5. busy_timeout: prevent SQLITE_BUSY starvation when NSE and main app + // are both active with WAL reader locks on the same file + sqlite3_exec(db, "PRAGMA busy_timeout = 500;", nil, nil, nil) + + // 6. Verify: a wrong key or corrupt file will fail here rather than at first use. + // PRAGMA key does not validate the key and prepare alone only parses SQL — + // the statement must be stepped so SQLCipher actually decrypts a page. + var stmt: OpaquePointer? + let prepareOk = sqlite3_prepare_v2(db, "SELECT count(*) FROM sqlite_master;", -1, &stmt, nil) == SQLITE_OK + let verifyOk = prepareOk && sqlite3_step(stmt) == SQLITE_ROW + sqlite3_finalize(stmt) + if !verifyOk { + NSLog("[Database] Open-verify failed for %@ — key may be wrong or file corrupt", dbName) + sqlite3_close(db) + db = nil + } + } + + // MARK: - Query API + + /// Execute a parameterised SELECT and return all rows as [String: Any]. + /// Args are bound as TEXT in order. + func query(_ sql: String, args: [String] = []) -> [[String: Any]]? { + guard db != nil else { return nil } + + var stmt: OpaquePointer? + var results: [[String: Any]] = [] + + guard sqlite3_prepare_v2(db, sql, -1, &stmt, nil) == SQLITE_OK else { + NSLog("[Database] Failed to prepare query") + return nil + } + defer { sqlite3_finalize(stmt) } + + for (i, arg) in args.enumerated() { + sqlite3_bind_text(stmt, Int32(i + 1), arg, -1, unsafeBitCast(-1, to: sqlite3_destructor_type.self)) + } + + while sqlite3_step(stmt) == SQLITE_ROW { + var row: [String: Any] = [:] + for col in 0..(_ result: [[String: Any]]) -> [T]? { + guard let data = try? JSONSerialization.data(withJSONObject: result), + let decoded = try? JSONDecoder().decode([T].self, from: data) else { return nil } + return decoded + } + + // MARK: - Typed helpers (called by Encryption.swift and RocketChat.swift) + + func readRoomEncryptionKey(for roomId: String) -> String? { + guard let rows = query("SELECT e2e_key FROM subscriptions WHERE rid = ? LIMIT 1", args: [roomId]), + let first = rows.first else { return nil } + return first["e2e_key"] as? String + } + + func readRoomEncrypted(for roomId: String) -> Bool { + guard let rows = query("SELECT encrypted FROM subscriptions WHERE rid = ? LIMIT 1", args: [roomId]), + let first = rows.first, + let val = first["encrypted"] as? NSNumber else { return false } + return val.boolValue + } } diff --git a/metro.config.js b/metro.config.js index 5b1d7cb76a9..c54a9f391ac 100644 --- a/metro.config.js +++ b/metro.config.js @@ -6,7 +6,7 @@ const { wrapWithReanimatedMetroConfig } = require('react-native-reanimated/metro const defaultConfig = getDefaultConfig(__dirname); -const sourceExts = [...defaultConfig.resolver.sourceExts, 'mjs']; +const sourceExts = [...defaultConfig.resolver.sourceExts, 'mjs', 'sql']; const config = { transformer: { diff --git a/package.json b/package.json index fb5fa57855b..8a0a48f07eb 100644 --- a/package.json +++ b/package.json @@ -132,6 +132,7 @@ "redux-saga": "1.1.3", "remove-markdown": "^0.3.0", "reselect": "4.0.0", + "rxjs": "7.8.2", "semver": "7.5.2", "transliteration": "2.3.5", "typed-redux-saga": "1.5.0", @@ -197,6 +198,7 @@ "@typescript-eslint/parser": "~7.18.0", "babel-jest": "~29.7.0", "babel-loader": "~9.1.3", + "babel-plugin-inline-import": "^3.0.0", "babel-plugin-react-compiler": "19.1.0-rc.3", "babel-plugin-transform-remove-console": "^6.9.4", "babel-preset-expo": "~54.0.9", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 1b87d2690b6..5cfa4a99b45 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -336,6 +336,9 @@ importers: reselect: specifier: 4.0.0 version: 4.0.0 + rxjs: + specifier: 7.8.2 + version: 7.8.2 semver: specifier: 7.5.2 version: 7.5.2 @@ -481,6 +484,9 @@ importers: babel-loader: specifier: ~9.1.3 version: 9.1.3(@babel/core@7.25.9)(webpack@5.106.2(esbuild@0.25.12)) + babel-plugin-inline-import: + specifier: ^3.0.0 + version: 3.0.0 babel-plugin-react-compiler: specifier: 19.1.0-rc.3 version: 19.1.0-rc.3 @@ -3614,6 +3620,9 @@ packages: '@babel/core': ^7.12.0 webpack: '>=5' + babel-plugin-inline-import@3.0.0: + resolution: {integrity: sha512-thnykl4FMb8QjMjVCuZoUmAM7r2mnTn5qJwrryCvDv6rugbJlTHZMctdjDtEgD0WBAXJOLJSGXN3loooEwx7UQ==} + babel-plugin-istanbul@6.1.1: resolution: {integrity: sha512-Y1IQok9821cC9onCx5otgFfRm7Lm+I+wwxOx738M/WLPZ9Q42m4IG5W0FNX8WLL2gYMZo3JkuXIH2DOpWM+qwA==} engines: {node: '>=8'} @@ -6532,6 +6541,9 @@ packages: resolution: {integrity: sha512-RjhtfwJOxzcFmNOi6ltcbcu4Iu+FL3zEj83dk4kAS+fVpTxXLO1b38RvJgT/0QwvV/L3aY9TAnyv0EOqW4GoMQ==} engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + path-extra@1.0.3: + resolution: {integrity: sha512-vYm3+GCkjUlT1rDvZnDVhNLXIRvwFPaN8ebHAFcuMJM/H0RBOPD7JrcldiNLd9AS3dhAyUHLa4Hny5wp1A+Ffw==} + path-is-absolute@1.0.1: resolution: {integrity: sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==} engines: {node: '>=0.10.0'} @@ -7126,6 +7138,9 @@ packages: require-main-filename@2.0.0: resolution: {integrity: sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==} + require-resolve@0.0.2: + resolution: {integrity: sha512-eafQVaxdQsWUB8HybwognkdcIdKdQdQBwTxH48FuE6WI0owZGKp63QYr1MRp73PoX0AcyB7MDapZThYUY8FD0A==} + requireg@0.2.2: resolution: {integrity: sha512-nYzyjnFcPNGR3lx9lwPPPnuQxv6JWEZd2Ci0u9opN7N5zUEPIhY/GbL3vMGOr2UXwEg9WwSyV9X9Y/kLFgPsOg==} engines: {node: '>= 4.0.0'} @@ -8160,6 +8175,9 @@ packages: utf-8-validate: optional: true + x-path@0.0.2: + resolution: {integrity: sha512-zQ4WFI0XfJN1uEkkrB19Y4TuXOlHqKSxUJo0Yt+axPjRm8tCG6SJ6+Wo3/+Kjg4c2c8IvBXuJ0uYoshxNn4qMw==} + xcode@3.0.1: resolution: {integrity: sha512-kCz5k7J7XbJtjABOvkc5lJmkiDh8VhjVCGNiqdKCscmVpdVUpEAyXv1xmCLkQJ5dsHqx3IPO4XW+NTDhU/fatA==} engines: {node: '>=10.0.0'} @@ -12001,6 +12019,10 @@ snapshots: schema-utils: 4.3.3 webpack: 5.106.2(esbuild@0.25.12) + babel-plugin-inline-import@3.0.0: + dependencies: + require-resolve: 0.0.2 + babel-plugin-istanbul@6.1.1: dependencies: '@babel/helper-plugin-utils': 7.28.6 @@ -15430,6 +15452,8 @@ snapshots: path-exists@5.0.0: {} + path-extra@1.0.3: {} + path-is-absolute@1.0.1: {} path-key@3.1.1: {} @@ -16095,6 +16119,10 @@ snapshots: require-main-filename@2.0.0: {} + require-resolve@0.0.2: + dependencies: + x-path: 0.0.2 + requireg@0.2.2: dependencies: nested-error-stacks: 2.0.1 @@ -17219,6 +17247,10 @@ snapshots: ws@8.18.3: {} + x-path@0.0.2: + dependencies: + path-extra: 1.0.3 + xcode@3.0.1: dependencies: simple-plist: 1.3.1