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/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/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..01fc4a201d7 --- /dev/null +++ b/app/lib/database/facade/Database.ts @@ -0,0 +1,126 @@ +/** + * 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 } from 'drizzle-orm/sqlite-core'; +import type { 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..d4741353a77 --- /dev/null +++ b/app/lib/database/facade/Model.ts @@ -0,0 +1,161 @@ +/** + * 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 = {}; + + /** Relation instance cache for @relation. */ + _relationCache: 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..e25078359df --- /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 { 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..ace2f9fcd88 --- /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. + * Uses rowId matching from the change event for precision. + */ +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/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 d5b8df00b5c..70aef819499 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: 28, 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/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/hooks/useFrequentlyUsedEmoji.ts b/app/lib/hooks/useFrequentlyUsedEmoji.ts index 581a02549ad..ae0ad12bdb2 100644 --- a/app/lib/hooks/useFrequentlyUsedEmoji.ts +++ b/app/lib/hooks/useFrequentlyUsedEmoji.ts @@ -1,6 +1,6 @@ import { useEffect, useState } from 'react'; -import { Q } from '@nozbe/watermelondb'; +import { Q } from '../database/facade'; import database from '../database'; import { type IEmoji } from '../../definitions'; import { DEFAULT_EMOJIS } from '../constants/emojis'; 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 9120e482361..ea4e9caf9f2 100644 --- a/app/lib/methods/emojis.ts +++ b/app/lib/methods/emojis.ts @@ -1,6 +1,4 @@ -import { sanitizedRaw } from '@nozbe/watermelondb/RawRecord'; -import { Q } from '@nozbe/watermelondb'; - +import { sanitizedRaw, Q } from '../database/facade'; import database from '../database'; import { 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 f4dbd8833dc..2a1f3d84ef8 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 0b04e886bab..7572cd5a1dd 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 { sanitizeLikeString, slugifyLikeString } 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/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 9d6048256f6..abee584a59f 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 650752eefec..2a40f3df95b 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 5b4816dd825..c78b352539b 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/Shared/RocketChat/Database.swift b/ios/Shared/RocketChat/Database.swift index 65fcbc5958a..d4af7ac3cd7 100644 --- a/ios/Shared/RocketChat/Database.swift +++ b/ios/Shared/RocketChat/Database.swift @@ -77,7 +77,11 @@ class Database { NSLog("[Database] App Group container unavailable — cannot open %@", dbName) return } - let path = (groupRoot as NSString).appendingPathComponent(dbName) + // 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))) 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 26c46cf8672..ab7fd6750d9 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 93824fbd9d7..766ff01b472 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'} @@ -8157,6 +8172,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'} @@ -11997,6 +12015,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 @@ -15426,6 +15448,8 @@ snapshots: path-exists@5.0.0: {} + path-extra@1.0.3: {} + path-is-absolute@1.0.1: {} path-key@3.1.1: {} @@ -16091,6 +16115,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 @@ -17213,6 +17241,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