From d6fa67e4926135cf80b7e62e2e80e521b918f808 Mon Sep 17 00:00:00 2001 From: Tasso Evangelista Date: Thu, 5 Jun 2025 23:53:56 -0300 Subject: [PATCH] regression: Threaded messages preview (#36165) --- .../autotranslate/client/lib/autotranslate.ts | 6 +- .../app/e2e/client/rocketchat.e2e.room.ts | 2 +- apps/meteor/app/e2e/client/rocketchat.e2e.ts | 2 +- .../models/client/models/CachedChatRoom.ts | 2 +- .../client/models/CachedChatSubscription.ts | 2 +- .../ui-utils/client/lib/RoomHistoryManager.ts | 4 +- .../lib/cachedCollections/CachedCollection.ts | 16 +- .../cachedCollections/MinimongoCollection.ts | 2 +- apps/meteor/client/startup/roles.ts | 2 +- .../client/views/room/hooks/useGoToRoom.ts | 2 +- .../room/providers/hooks/useRoomQuery.ts | 2 +- packages/mongo-adapter/jest.config.ts | 6 + packages/mongo-adapter/package.json | 6 +- packages/mongo-adapter/src/filter.spec.ts | 203 ++++++++++++++++++ packages/mongo-adapter/src/filter.ts | 23 +- yarn.lock | 2 + 16 files changed, 257 insertions(+), 25 deletions(-) create mode 100644 packages/mongo-adapter/jest.config.ts create mode 100644 packages/mongo-adapter/src/filter.spec.ts diff --git a/apps/meteor/app/autotranslate/client/lib/autotranslate.ts b/apps/meteor/app/autotranslate/client/lib/autotranslate.ts index e6584467343..eee21af762a 100644 --- a/apps/meteor/app/autotranslate/client/lib/autotranslate.ts +++ b/apps/meteor/app/autotranslate/client/lib/autotranslate.ts @@ -146,7 +146,7 @@ export const createAutoTranslateMessageStreamHandler = (): ((message: ITranslate (!message.translations || (!hasTranslationLanguageInMessage(message, language) && !hasTranslationLanguageInAttachments(message.attachments, language))) ) { - Messages.store.update( + Messages.state.update( (record) => record._id === message._id, (record) => ({ ...record, @@ -154,7 +154,7 @@ export const createAutoTranslateMessageStreamHandler = (): ((message: ITranslate }), ); } else if (AutoTranslate.messageIdsToWait[message._id] !== undefined && subscription && subscription.autoTranslate !== true) { - Messages.store.update( + Messages.state.update( (record) => record._id === message._id, ({ autoTranslateFetching: _, ...record }) => ({ ...record, @@ -163,7 +163,7 @@ export const createAutoTranslateMessageStreamHandler = (): ((message: ITranslate ); delete AutoTranslate.messageIdsToWait[message._id]; } else if (message.autoTranslateFetching === true) { - Messages.store.update( + Messages.state.update( (record) => record._id === message._id, ({ autoTranslateFetching: _, ...record }) => record, ); diff --git a/apps/meteor/app/e2e/client/rocketchat.e2e.room.ts b/apps/meteor/app/e2e/client/rocketchat.e2e.room.ts index 470df766104..19a0cf3b134 100644 --- a/apps/meteor/app/e2e/client/rocketchat.e2e.room.ts +++ b/apps/meteor/app/e2e/client/rocketchat.e2e.room.ts @@ -303,7 +303,7 @@ export class E2ERoom extends Emitter { } async decryptPendingMessages() { - await Messages.store.updateAsync( + await Messages.state.updateAsync( (record) => record.rid === this.roomId && record.t === 'e2e' && record.e2e === 'pending', (record) => this.decryptMessage(record), ); diff --git a/apps/meteor/app/e2e/client/rocketchat.e2e.ts b/apps/meteor/app/e2e/client/rocketchat.e2e.ts index 117bf20f055..cbfc5f5578c 100644 --- a/apps/meteor/app/e2e/client/rocketchat.e2e.ts +++ b/apps/meteor/app/e2e/client/rocketchat.e2e.ts @@ -716,7 +716,7 @@ class E2E extends Emitter { } async decryptPendingMessages(): Promise { - await Messages.store.updateAsync( + await Messages.state.updateAsync( (record) => record.t === 'e2e' && record.e2e === 'pending', (record) => this.decryptMessage(record), ); diff --git a/apps/meteor/app/models/client/models/CachedChatRoom.ts b/apps/meteor/app/models/client/models/CachedChatRoom.ts index 0b903adc39f..0c805955a3b 100644 --- a/apps/meteor/app/models/client/models/CachedChatRoom.ts +++ b/apps/meteor/app/models/client/models/CachedChatRoom.ts @@ -25,7 +25,7 @@ class CachedChatRoom extends PrivateCachedCollection { } private mergeWithSubscription(room: IRoom): IRoom { - CachedChatSubscription.collection.store.update( + CachedChatSubscription.collection.state.update( (record) => record.rid === room._id, (sub) => ({ ...sub, diff --git a/apps/meteor/app/models/client/models/CachedChatSubscription.ts b/apps/meteor/app/models/client/models/CachedChatSubscription.ts index 1623b1e4004..5977e0b1d70 100644 --- a/apps/meteor/app/models/client/models/CachedChatSubscription.ts +++ b/apps/meteor/app/models/client/models/CachedChatSubscription.ts @@ -33,7 +33,7 @@ class CachedChatSubscription extends PrivateCachedCollection record._id === subscription.rid); + const room = CachedChatRoom.collection.state.find((record) => record._id === subscription.rid); const lastRoomUpdate = room?.lm || subscription.ts || room?.ts; diff --git a/apps/meteor/app/ui-utils/client/lib/RoomHistoryManager.ts b/apps/meteor/app/ui-utils/client/lib/RoomHistoryManager.ts index c14f30c977a..1ddd44f21b2 100644 --- a/apps/meteor/app/ui-utils/client/lib/RoomHistoryManager.ts +++ b/apps/meteor/app/ui-utils/client/lib/RoomHistoryManager.ts @@ -30,7 +30,7 @@ const processMessage = async (msg: IMessage & { ignored?: boolean }, { subscript }; export async function upsertMessage({ msg, subscription }: { msg: IMessage & { ignored?: boolean }; subscription?: ISubscription }) { - Messages.store.store(await processMessage(msg, { subscription })); + Messages.state.store(await processMessage(msg, { subscription })); } export async function upsertMessageBulk({ @@ -41,7 +41,7 @@ export async function upsertMessageBulk({ subscription?: ISubscription; }) { const processedMsgs = await Promise.all(msgs.map(async (msg) => processMessage(msg, { subscription }))); - Messages.store.storeMany(processedMsgs); + Messages.state.storeMany(processedMsgs); } const defaultLimit = parseInt(getConfig('roomListLimit') ?? '50') || 50; diff --git a/apps/meteor/client/lib/cachedCollections/CachedCollection.ts b/apps/meteor/client/lib/cachedCollections/CachedCollection.ts index a046e537c0d..fb1c141fb3d 100644 --- a/apps/meteor/client/lib/cachedCollections/CachedCollection.ts +++ b/apps/meteor/client/lib/cachedCollections/CachedCollection.ts @@ -109,7 +109,7 @@ export abstract class CachedCollection { this.updatedAt = new Date(updatedAt); } - this.collection.store.replaceAll(deserializedRecords.filter(hasId)); + this.collection.state.replaceAll(deserializedRecords.filter(hasId)); this.updatedAt = data.updatedAt || this.updatedAt; @@ -155,7 +155,7 @@ export abstract class CachedCollection { } }); - this.collection.store.storeMany(newRecords); + this.collection.state.storeMany(newRecords); this.updatedAt = this.updatedAt === lastTime ? startTime : this.updatedAt; } @@ -178,7 +178,7 @@ export abstract class CachedCollection { private save = withDebouncing({ wait: 1000 })(async () => { this.log('saving cache'); - const data = this.collection.store.records; + const data = this.collection.state.records; await localforage.setItem(this.name, { updatedAt: this.updatedAt, version: this.version, @@ -193,7 +193,7 @@ export abstract class CachedCollection { protected async clearCache() { this.log('clearing cache'); await localforage.removeItem(this.name); - this.collection.store.replaceAll([]); + this.collection.state.replaceAll([]); } protected async setupListener() { @@ -211,11 +211,11 @@ export abstract class CachedCollection { } if (action === 'removed') { - this.collection.store.delete(newRecord); + this.collection.state.delete(newRecord); } else { const { _id } = newRecord; if (!_id) return; - this.collection.store.store(newRecord); + this.collection.state.store(newRecord); } await this.save(); } @@ -257,7 +257,7 @@ export abstract class CachedCollection { const actionTime = hasUpdatedAt(newRecord) ? newRecord._updatedAt : startTime; changes.push({ action: () => { - this.collection.store.store(newRecord); + this.collection.state.store(newRecord); if (actionTime > this.updatedAt) { this.updatedAt = actionTime; } @@ -280,7 +280,7 @@ export abstract class CachedCollection { const actionTime = newRecord._deletedAt; changes.push({ action: () => { - this.collection.store.delete(newRecord); + this.collection.state.delete(newRecord); if (actionTime > this.updatedAt) { this.updatedAt = actionTime; } diff --git a/apps/meteor/client/lib/cachedCollections/MinimongoCollection.ts b/apps/meteor/client/lib/cachedCollections/MinimongoCollection.ts index 44bdfff063a..8cc664de75a 100644 --- a/apps/meteor/client/lib/cachedCollections/MinimongoCollection.ts +++ b/apps/meteor/client/lib/cachedCollections/MinimongoCollection.ts @@ -89,7 +89,7 @@ export class MinimongoCollection extends Mongo.Collec * * It's a convenience method to access the Zustand store directly i.e. outside of React components. */ - get store() { + get state() { return this.use.getState(); } diff --git a/apps/meteor/client/startup/roles.ts b/apps/meteor/client/startup/roles.ts index 6b8397c991b..6ea36a3475f 100644 --- a/apps/meteor/client/startup/roles.ts +++ b/apps/meteor/client/startup/roles.ts @@ -10,7 +10,7 @@ Meteor.startup(() => { onLoggedIn(async () => { const { roles } = await sdk.rest.get('/v1/roles.list'); // if a role is checked before this collection is populated, it will return undefined - Roles.store.replaceAll(roles); + Roles.state.replaceAll(roles); Roles.ready.set(true); }); diff --git a/apps/meteor/client/views/room/hooks/useGoToRoom.ts b/apps/meteor/client/views/room/hooks/useGoToRoom.ts index ec7530173e0..c1b8908fc2b 100644 --- a/apps/meteor/client/views/room/hooks/useGoToRoom.ts +++ b/apps/meteor/client/views/room/hooks/useGoToRoom.ts @@ -15,7 +15,7 @@ export const useGoToRoom = ({ replace = false }: { replace?: boolean } = {}): (( return; } - const subscription: ISubscription | undefined = Subscriptions.store.find((record) => record.rid === rid); + const subscription: ISubscription | undefined = Subscriptions.state.find((record) => record.rid === rid); if (subscription) { roomCoordinator.openRouteLink(subscription.t, subscription, router.getSearchParameters(), { replace }); diff --git a/apps/meteor/client/views/room/providers/hooks/useRoomQuery.ts b/apps/meteor/client/views/room/providers/hooks/useRoomQuery.ts index bc6e2ef44fb..09939ae454a 100644 --- a/apps/meteor/client/views/room/providers/hooks/useRoomQuery.ts +++ b/apps/meteor/client/views/room/providers/hooks/useRoomQuery.ts @@ -14,7 +14,7 @@ export function useRoomQuery( const queryResult = useQuery({ queryKey: roomsQueryKeys.room(rid), queryFn: async () => { - const room = Rooms.store.get(rid); + const room = Rooms.state.get(rid); if (!room) { throw new RoomNotFoundError(undefined, { rid }); diff --git a/packages/mongo-adapter/jest.config.ts b/packages/mongo-adapter/jest.config.ts new file mode 100644 index 00000000000..c18c8ae0246 --- /dev/null +++ b/packages/mongo-adapter/jest.config.ts @@ -0,0 +1,6 @@ +import server from '@rocket.chat/jest-presets/server'; +import type { Config } from 'jest'; + +export default { + preset: server.preset, +} satisfies Config; diff --git a/packages/mongo-adapter/package.json b/packages/mongo-adapter/package.json index da0b1d3e49c..81a8ad00e16 100644 --- a/packages/mongo-adapter/package.json +++ b/packages/mongo-adapter/package.json @@ -3,14 +3,18 @@ "version": "0.0.2", "private": true, "devDependencies": { + "@rocket.chat/jest-presets": "workspace:~", "eslint": "~8.45.0", + "jest": "~29.7.0", "typescript": "~5.8.3" }, "scripts": { "lint": "eslint --ext .js,.jsx,.ts,.tsx .", "lint:fix": "eslint --ext .js,.jsx,.ts,.tsx . --fix", "build": "rm -rf dist && tsc -p tsconfig.json", - "dev": "tsc -p tsconfig.json --watch --preserveWatchOutput" + "dev": "tsc -p tsconfig.json --watch --preserveWatchOutput", + "test": "jest", + "testunit": "jest" }, "main": "./dist/index.js", "typings": "./dist/index.d.ts", diff --git a/packages/mongo-adapter/src/filter.spec.ts b/packages/mongo-adapter/src/filter.spec.ts new file mode 100644 index 00000000000..d0dec8059be --- /dev/null +++ b/packages/mongo-adapter/src/filter.spec.ts @@ -0,0 +1,203 @@ +import { compileFilter } from './filter'; + +describe('compileFilter', () => { + it('matches simple equality', () => { + const filter = { foo: 'bar' }; + const fn = compileFilter(filter); + expect(fn({ foo: 'bar' })).toBe(true); + expect(fn({ foo: 'baz' })).toBe(false); + }); + + it('matches $in', () => { + const filter = { foo: { $in: ['a', 'b'] } }; + const fn = compileFilter(filter); + expect(fn({ foo: 'a' })).toBe(true); + expect(fn({ foo: 'b' })).toBe(true); + expect(fn({ foo: 'c' })).toBe(false); + }); + + it('matches $nin', () => { + const filter = { foo: { $nin: ['a', 'b'] } }; + const fn = compileFilter(filter); + expect(fn({ foo: 'a' })).toBe(false); + expect(fn({ foo: 'c' })).toBe(true); + expect(fn({})).toBe(true); + }); + + it('matches $all', () => { + const filter = { foo: { $all: ['a', 'b'] } }; + const fn = compileFilter(filter); + expect(fn({ foo: ['a', 'b', 'c'] })).toBe(true); + expect(fn({ foo: ['a', 'c'] })).toBe(false); + }); + + it('matches $lt/$lte/$gt/$gte', () => { + const filter = { foo: { $lt: 5 } }; + const fn = compileFilter(filter); + expect(fn({ foo: 4 })).toBe(true); + expect(fn({ foo: 5 })).toBe(false); + const filter2 = { foo: { $lte: 5 } }; + const fn2 = compileFilter(filter2); + expect(fn2({ foo: 5 })).toBe(true); + const filter3 = { foo: { $gt: 5 } }; + const fn3 = compileFilter(filter3); + expect(fn3({ foo: 6 })).toBe(true); + const filter4 = { foo: { $gte: 5 } }; + const fn4 = compileFilter(filter4); + expect(fn4({ foo: 5 })).toBe(true); + }); + + it('matches $ne', () => { + const filter = { foo: { $ne: 1 } }; + const fn = compileFilter(filter); + expect(fn({ foo: 2 })).toBe(true); + expect(fn({ foo: 1 })).toBe(false); + }); + + it('matches $exists', () => { + const filter = { foo: { $exists: true } }; + const fn = compileFilter(filter); + expect(fn({ foo: 1 })).toBe(true); + expect(fn({})).toBe(false); + const filter2 = { foo: { $exists: false } }; + const fn2 = compileFilter(filter2); + expect(fn2({ foo: 1 })).toBe(false); + expect(fn2({})).toBe(true); + }); + + it('matches $mod', () => { + const filter = { foo: { $mod: [2, 0] } }; + const fn = compileFilter(filter); + expect(fn({ foo: 4 })).toBe(true); + expect(fn({ foo: 5 })).toBe(false); + }); + + it('matches $size', () => { + const filter = { foo: { $size: 2 } }; + const fn = compileFilter(filter); + expect(fn({ foo: [1, 2] })).toBe(true); + expect(fn({ foo: [1] })).toBe(false); + }); + + it('matches $type', () => { + const filter = { foo: { $type: 2 } }; + const fn = compileFilter(filter); + expect(fn({ foo: 'bar' })).toBe(true); + expect(fn({ foo: 1 })).toBe(false); + }); + + it('matches $regex', () => { + const filter = { foo: { $regex: '^b' } }; + const fn = compileFilter(filter); + expect(fn({ foo: 'bar' })).toBe(true); + expect(fn({ foo: 'baz' })).toBe(true); + expect(fn({ foo: 'car' })).toBe(false); + }); + + it('matches $elemMatch', () => { + const filter = { foo: { $elemMatch: { bar: 1 } } }; + const fn = compileFilter(filter); + expect(fn({ foo: [{ bar: 1 }, { bar: 2 }] })).toBe(true); + expect(fn({ foo: [{ bar: 2 }] })).toBe(false); + }); + + it('matches $eq', () => { + const filter = { foo: { $eq: 1 } }; + const fn = compileFilter(filter); + expect(fn({ foo: 1 })).toBe(true); + expect(fn({ foo: 2 })).toBe(false); + }); + + it('matches $not', () => { + const filter = { foo: { $not: { $eq: 1 } } }; + const fn = compileFilter(filter); + expect(fn({ foo: 2 })).toBe(true); + expect(fn({ foo: 1 })).toBe(false); + }); + + it('matches $and', () => { + const filter = { $and: [{ foo: 1 }, { bar: 2 }] }; + const fn = compileFilter(filter); + expect(fn({ foo: 1, bar: 2 })).toBe(true); + expect(fn({ foo: 1, bar: 3 })).toBe(false); + }); + + it('matches $or', () => { + const filter = { $or: [{ foo: 1 }, { bar: 2 }] }; + const fn = compileFilter(filter); + expect(fn({ foo: 1 })).toBe(true); + expect(fn({ bar: 2 })).toBe(true); + expect(fn({ foo: 3, bar: 4 })).toBe(false); + }); + + it('matches $nor', () => { + const filter = { $nor: [{ foo: 1 }, { bar: 2 }] }; + const fn = compileFilter(filter); + expect(fn({ foo: 1 })).toBe(false); + expect(fn({ bar: 2 })).toBe(false); + expect(fn({ foo: 3, bar: 4 })).toBe(true); + }); + + it('matches $where (function)', () => { + const filter = { $where: (doc: any) => doc.foo === 1 }; + const fn = compileFilter(filter); + expect(fn({ foo: 1 })).toBe(true); + expect(fn({ foo: 2 })).toBe(false); + }); + + it('matches $where (string)', () => { + const filter = { $where: 'this.foo === 1' }; + const fn = compileFilter(filter); + expect(fn({ foo: 1 })).toBe(true); + expect(fn({ foo: 2 })).toBe(false); + }); + + it('handles undefined and null', () => { + const filter = { foo: undefined, bar: null }; + const fn = compileFilter(filter); + expect(fn({})).toBe(true); + expect(fn({ foo: null, bar: null })).toBe(true); + expect(fn({ foo: 1, bar: 2 })).toBe(false); + }); + + it('handles nested fields', () => { + const filter = { 'foo.bar': 1 }; + const fn = compileFilter(filter); + expect(fn({ foo: { bar: 1 } })).toBe(true); + expect(fn({ foo: { bar: 2 } })).toBe(false); + }); + + it('handles arrays for $in/$all', () => { + const filter = { foo: { $in: [1, 2] }, bar: { $all: [3, 4] } }; + const fn = compileFilter(filter); + expect(fn({ foo: 1, bar: [3, 4, 5] })).toBe(true); + expect(fn({ foo: 3, bar: [3, 4, 5] })).toBe(false); + }); + + it('returns true for empty filter', () => { + const fn = compileFilter({}); + expect(fn({})).toBe(true); + expect(fn({ foo: 1 })).toBe(true); + }); + + it('handles multiple keys', () => { + const filter = { foo: 1, bar: 2 }; + const fn = compileFilter(filter); + expect(fn({ foo: 1, bar: 2 })).toBe(true); + expect(fn({ foo: 1, bar: 3 })).toBe(false); + }); + + it('handles complex nested structures', () => { + const filter = { $or: [{ a: { $exists: false } }, { b: { $eq: true } }] }; + const fn = compileFilter(filter); + expect(fn({ a: undefined, b: true })).toBe(true); + expect(fn({ a: '123', b: true })).toBe(true); + expect(fn({ a: '123', b: false })).toBe(false); + expect(fn({ a: undefined, b: false })).toBe(true); + }); + + it('throws error for unsupported operators', () => { + const filter = { $unsupported: 'value' }; + expect(() => compileFilter(filter)).toThrow('Unrecognized logical operator: $unsupported'); + }); +}); diff --git a/packages/mongo-adapter/src/filter.ts b/packages/mongo-adapter/src/filter.ts index fa9d7c18803..3183b9e68b7 100644 --- a/packages/mongo-adapter/src/filter.ts +++ b/packages/mongo-adapter/src/filter.ts @@ -47,6 +47,16 @@ const $all = return operand.every((operandElement) => value.some((valueElement) => equals(operandElement, valueElement))); }; +const $eq = + (operand: T, _options: undefined): ((value: T) => boolean) => + (value: T): boolean => { + if (value === undefined) { + return false; + } + + return flatSome(value, (x) => equals(x, operand)); + }; + const $lt = (operand: T, _options: undefined): ((value: T) => boolean) => (value: T): boolean => @@ -130,7 +140,9 @@ const $elemMatch = (operand: Filter, _options: undefined): ((value: T) => const $not = (operand: FieldExpression, _options: undefined): ((value: T) => boolean) => { const matcher = compileValueSelector(operand); - return (value: T): boolean => !matcher(value); + return (value: T): boolean => { + return !matcher(value); + }; }; const dummyOperator = @@ -143,6 +155,7 @@ const $near = dummyOperator; const $geoIntersects = dummyOperator; const valueOperators = { + $eq, $in, $nin, $all, @@ -179,8 +192,8 @@ const $nor = (subSelector: Filter[]): ((doc: T) => boolean) => { }; const $where = (selectorValue: string | ((doc: T) => boolean)): ((doc: T) => boolean) => { - const fn = selectorValue instanceof Function ? selectorValue : Function(`return ${selectorValue}`); - return (doc: T): boolean => !!fn.call(doc); + const fn = selectorValue instanceof Function ? selectorValue : Function(`doc`, `return ${selectorValue}`); + return (doc: T): boolean => !!fn.call(doc, doc); }; const logicalOperators = { @@ -287,6 +300,10 @@ export const compileFilter = (filter: Filter | FieldExpression['$where' } } + if (key.slice(0, 1) === '$') { + throw new Error(`Unrecognized logical operator: ${key}`); + } + const lookUpByIndex = createLookupFunction(key); const valueSelectorFunc = compileValueSelector(subSelector); return (doc: T): boolean => { diff --git a/yarn.lock b/yarn.lock index 63bac1ca106..76c109a5c9f 100644 --- a/yarn.lock +++ b/yarn.lock @@ -9233,7 +9233,9 @@ __metadata: version: 0.0.0-use.local resolution: "@rocket.chat/mongo-adapter@workspace:packages/mongo-adapter" dependencies: + "@rocket.chat/jest-presets": "workspace:~" eslint: "npm:~8.45.0" + jest: "npm:~29.7.0" typescript: "npm:~5.8.3" languageName: unknown linkType: soft