Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 33 additions & 0 deletions .eslintrc.js
Original file line number Diff line number Diff line change
@@ -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': {
Expand Down Expand Up @@ -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 }]
}
}
]
};
2 changes: 1 addition & 1 deletion app/containers/Avatar/useAvatarETag.ts
Original file line number Diff line number Diff line change
@@ -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';

Expand Down
2 changes: 1 addition & 1 deletion app/containers/MessageComposer/MessageComposer.tsx
Original file line number Diff line number Diff line change
@@ -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';
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
2 changes: 1 addition & 1 deletion app/containers/MessageComposer/hooks/useAutocomplete.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand Down
2 changes: 1 addition & 1 deletion app/containers/MessageErrorActions.tsx
Original file line number Diff line number Diff line change
@@ -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';
Expand Down
2 changes: 1 addition & 1 deletion app/definitions/IEmoji.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import type Model from '@nozbe/watermelondb/Model';
import type { Model } from '../lib/database/facade';

export interface IFrequentlyUsedEmoji {
content: string;
Expand Down
3 changes: 1 addition & 2 deletions app/definitions/ILoggedUser.ts
Original file line number Diff line number Diff line change
@@ -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';

Expand Down
2 changes: 1 addition & 1 deletion app/definitions/IMessage.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down
2 changes: 1 addition & 1 deletion app/definitions/IPermission.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import type Model from '@nozbe/watermelondb/Model';
import type { Model } from '../lib/database/facade';

export interface IPermission {
_id: string;
Expand Down
2 changes: 1 addition & 1 deletion app/definitions/IRole.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import type Model from '@nozbe/watermelondb/Model';
import type { Model } from '../lib/database/facade';

export interface IRole {
id: string;
Expand Down
3 changes: 1 addition & 2 deletions app/definitions/IRoom.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down
3 changes: 1 addition & 2 deletions app/definitions/IServer.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down
2 changes: 1 addition & 1 deletion app/definitions/IServerHistory.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import type Model from '@nozbe/watermelondb/Model';
import type { Model } from '../lib/database/facade';

export interface IServerHistory {
id: string;
Expand Down
2 changes: 1 addition & 1 deletion app/definitions/ISettings.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import type Model from '@nozbe/watermelondb/Model';
import type { Model } from '../lib/database/facade';

export interface ISettings {
id: string;
Expand Down
2 changes: 1 addition & 1 deletion app/definitions/ISlashCommand.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import type Model from '@nozbe/watermelondb/Model';
import type { Model } from '../lib/database/facade';

export interface ISlashCommand {
id: string;
Expand Down
4 changes: 1 addition & 3 deletions app/definitions/ISubscription.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down
2 changes: 1 addition & 1 deletion app/definitions/IThread.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down
3 changes: 1 addition & 2 deletions app/definitions/IThreadMessage.ts
Original file line number Diff line number Diff line change
@@ -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 {
Expand Down
2 changes: 1 addition & 1 deletion app/definitions/IUpload.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import type Model from '@nozbe/watermelondb/Model';
import type { Model } from '../lib/database/facade';

export interface IUpload {
id?: string;
Expand Down
3 changes: 1 addition & 2 deletions app/definitions/IUser.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down
33 changes: 33 additions & 0 deletions app/lib/database/driver/__tests__/connection.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
}
}));

Expand All @@ -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' }
Expand Down Expand Up @@ -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
// ---------------------------------------------------------------------------
Expand Down
142 changes: 142 additions & 0 deletions app/lib/database/facade/Collection.ts
Original file line number Diff line number Diff line change
@@ -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<M extends Model = Model> {
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<M>, raw: RawRecord) => M;

constructor(
table: string,
schema: TableSchema,
drizzleTable: SQLiteTable,
handle: DbHandle,
ModelClass: new (col: Collection<M>, 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<M> {
return this;
}

// ---------------------------------------------------------------------------
// Internal fetch helpers (synchronous — Drizzle expo-sqlite is sync)
// ---------------------------------------------------------------------------

/** Synchronous full fetch — used by observe and find. */
_fetchSync(filter?: Record<string, unknown>): 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<number>`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<M[]> {
return observeTable(this._handle, this.table, () => this._fetchAll(clauses)) as unknown as Observable<M[]>;
}

_observeWithColumns(clauses: Q.Clause[], cols: string[]): Observable<M[]> {
return observeTableWithColumns(this._handle, this.table, cols, () => this._fetchAll(clauses)) as unknown as Observable<M[]>;
}

// ---------------------------------------------------------------------------
// Public API
// ---------------------------------------------------------------------------

/** Build a Query for this collection. Accepts spread clauses or a single array (WMDB parity). */
query(...clauses: (Q.Clause | Q.Clause[])[]): Query<M> {
return new Query<M>(this, clauses.flat());
}

/** Find a record by id. Rejects when missing (WMDB parity). */
find(id: string): Promise<M> {
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<M> {
return this._db.write(async () => {
const model = this.prepareCreate(fn);
await this._db.batch(model);
return model;
});
}
}
Loading