snapshot of https://github.com/RocketChat/Rocket.Chat.git (develop, 2026-09-16; blobs > 131072B omitted)
Some checks failed
Create issues from TODOs / todos (push) Failing after 3s
Close inactive issues / close-issues (push) Has been cancelled
Deploy GitHub Pages / deploy-preview (push) Has been cancelled
CI / ⚙️ Variables Setup (push) Has been cancelled
CI / ⚙️ Test Guard (push) Has been cancelled
CI / 🚀 Notify external services - draft (push) Has been cancelled
CI / 📦 Build Packages (push) Has been cancelled
CI / 📦 Meteor Build (coverage) (push) Has been cancelled
CI / 📦 Meteor Build (production) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [account-service presence-service omnichannel-transcript-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [account-service presence-service omnichannel-transcript-service], fips) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [authorization-service queue-worker-service ddp-streamer-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [authorization-service queue-worker-service ddp-streamer-service], fips) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [rocketchat], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [rocketchat], coverage) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [rocketchat], fips) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [account-service presence-service omnichannel-transcript-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [authorization-service queue-worker-service ddp-streamer-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [rocketchat], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [rocketchat], coverage) (push) Has been cancelled
CI / 🚢 Publish Docker Images (ghcr.io) (push) Has been cancelled
CI / 📦 Track Image Sizes (push) Has been cancelled
CI / 🔎 Code Check - Action Lint (push) Has been cancelled
CI / 🔎 Code Check (push) Has been cancelled
CI / 🔨 Test Storybook (push) Has been cancelled
CI / 🔨 Test Unit (push) Has been cancelled
CI / 🔨 Test API (CE) (push) Has been cancelled
CI / 🔨 Test API Livechat (CE) (push) Has been cancelled
CI / 🔨 Test UI (CE) (push) Has been cancelled
CI / 🔨 Test API (EE) (push) Has been cancelled
CI / 🔨 Test API Livechat (EE) (push) Has been cancelled
CI / 🔨 Test API Apps (deno-runtime - EE) (push) Has been cancelled
CI / 🔨 Test UI (EE) (push) Has been cancelled
CI / 🔨 Test API (FIPS) (push) Has been cancelled
CI / 🔨 Test API Livechat (FIPS) (push) Has been cancelled
CI / 🔨 Test UI (FIPS) (push) Has been cancelled
CI / 🔨 Test Federation Matrix (push) Has been cancelled
CI / 📊 Report Coverage (push) Has been cancelled
CI / ✅ Tests Done (push) Has been cancelled
CI / 🚀 Publish build assets (push) Has been cancelled
CI / 🚀 Publish Docker Images (DockerHub) (push) Has been cancelled
CI / 🚀 Notify external services (push) Has been cancelled
CI / Update Version Durability (push) Has been cancelled
Release / Release (push) Has been cancelled
Auto-close duplicate issues / auto-close-duplicates (push) Failing after 1s
Code scanning - action / CodeQL-Build (push) Failing after 1s
Some checks failed
Create issues from TODOs / todos (push) Failing after 3s
Close inactive issues / close-issues (push) Has been cancelled
Deploy GitHub Pages / deploy-preview (push) Has been cancelled
CI / ⚙️ Variables Setup (push) Has been cancelled
CI / ⚙️ Test Guard (push) Has been cancelled
CI / 🚀 Notify external services - draft (push) Has been cancelled
CI / 📦 Build Packages (push) Has been cancelled
CI / 📦 Meteor Build (coverage) (push) Has been cancelled
CI / 📦 Meteor Build (production) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [account-service presence-service omnichannel-transcript-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [account-service presence-service omnichannel-transcript-service], fips) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [authorization-service queue-worker-service ddp-streamer-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [authorization-service queue-worker-service ddp-streamer-service], fips) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [rocketchat], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [rocketchat], coverage) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [rocketchat], fips) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [account-service presence-service omnichannel-transcript-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [authorization-service queue-worker-service ddp-streamer-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [rocketchat], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [rocketchat], coverage) (push) Has been cancelled
CI / 🚢 Publish Docker Images (ghcr.io) (push) Has been cancelled
CI / 📦 Track Image Sizes (push) Has been cancelled
CI / 🔎 Code Check - Action Lint (push) Has been cancelled
CI / 🔎 Code Check (push) Has been cancelled
CI / 🔨 Test Storybook (push) Has been cancelled
CI / 🔨 Test Unit (push) Has been cancelled
CI / 🔨 Test API (CE) (push) Has been cancelled
CI / 🔨 Test API Livechat (CE) (push) Has been cancelled
CI / 🔨 Test UI (CE) (push) Has been cancelled
CI / 🔨 Test API (EE) (push) Has been cancelled
CI / 🔨 Test API Livechat (EE) (push) Has been cancelled
CI / 🔨 Test API Apps (deno-runtime - EE) (push) Has been cancelled
CI / 🔨 Test UI (EE) (push) Has been cancelled
CI / 🔨 Test API (FIPS) (push) Has been cancelled
CI / 🔨 Test API Livechat (FIPS) (push) Has been cancelled
CI / 🔨 Test UI (FIPS) (push) Has been cancelled
CI / 🔨 Test Federation Matrix (push) Has been cancelled
CI / 📊 Report Coverage (push) Has been cancelled
CI / ✅ Tests Done (push) Has been cancelled
CI / 🚀 Publish build assets (push) Has been cancelled
CI / 🚀 Publish Docker Images (DockerHub) (push) Has been cancelled
CI / 🚀 Notify external services (push) Has been cancelled
CI / Update Version Durability (push) Has been cancelled
Release / Release (push) Has been cancelled
Auto-close duplicate issues / auto-close-duplicates (push) Failing after 1s
Code scanning - action / CodeQL-Build (push) Failing after 1s
This commit is contained in:
403
apps/meteor/client/lib/cachedStores/CachedStore.ts
Normal file
403
apps/meteor/client/lib/cachedStores/CachedStore.ts
Normal file
@@ -0,0 +1,403 @@
|
||||
import type { IRocketChatRecord } from '@rocket.chat/core-typings';
|
||||
import type { StreamNames } from '@rocket.chat/ddp-client';
|
||||
import { isTruthy } from '@rocket.chat/tools';
|
||||
import localforage from 'localforage';
|
||||
import { Meteor } from 'meteor/meteor';
|
||||
import { create, type StoreApi, type UseBoundStore } from 'zustand';
|
||||
|
||||
import { baseURI } from '../baseURI';
|
||||
import { onLoggedIn } from '../loggedIn';
|
||||
import { CachedStoresManager } from './CachedStoresManager';
|
||||
import type { IDocumentMapStore } from './DocumentMapStore';
|
||||
import { withDebouncing } from '../../../lib/utils/highOrderFunctions';
|
||||
import { sdk } from '../SDKClient';
|
||||
import { getDdpSdk } from '../sdk/ddpSdk';
|
||||
import { STORAGE_KEYS, getStoredItem } from '../sdk/storage';
|
||||
import { getUserId } from '../user';
|
||||
import { getConfig } from '../utils/getConfig';
|
||||
|
||||
type Name = 'rooms' | 'subscriptions' | 'permissions' | 'public-settings' | 'private-settings';
|
||||
|
||||
const hasId = <T>(record: T): record is T & { _id: string } => typeof record === 'object' && record !== null && '_id' in record;
|
||||
const hasUpdatedAt = <T>(record: T): record is T & { _updatedAt: Date } =>
|
||||
typeof record === 'object' &&
|
||||
record !== null &&
|
||||
'_updatedAt' in record &&
|
||||
(record as unknown as { _updatedAt: unknown })._updatedAt instanceof Date;
|
||||
const hasDeletedAt = <T>(record: T): record is T & { _deletedAt: Date } =>
|
||||
typeof record === 'object' &&
|
||||
record !== null &&
|
||||
'_deletedAt' in record &&
|
||||
(record as unknown as { _deletedAt: unknown })._deletedAt instanceof Date;
|
||||
const hasUnserializedUpdatedAt = <T>(record: T): record is T & { _updatedAt: ConstructorParameters<typeof Date>[0] } =>
|
||||
typeof record === 'object' &&
|
||||
record !== null &&
|
||||
'_updatedAt' in record &&
|
||||
!((record as unknown as { _updatedAt: unknown })._updatedAt instanceof Date);
|
||||
|
||||
localforage.config({ name: baseURI });
|
||||
|
||||
export interface IWithManageableCache {
|
||||
clearCacheOnLogout(): void;
|
||||
}
|
||||
|
||||
export abstract class CachedStore<T extends IRocketChatRecord, U = T> implements IWithManageableCache {
|
||||
private static readonly MAX_CACHE_TIME = 60 * 60 * 24 * 30;
|
||||
|
||||
readonly store: UseBoundStore<StoreApi<IDocumentMapStore<T>>>;
|
||||
|
||||
protected name: Name;
|
||||
|
||||
protected eventType: StreamNames;
|
||||
|
||||
// Bumped from 18 → 19 to invalidate caches populated before the DDPSDK
|
||||
// wire encoding was switched from JSON to EJSON. Entries written by the
|
||||
// JSON window stored dates as ISO strings instead of Date instances, so
|
||||
// fields like subscription.ls would fail `.getTime()` when read back.
|
||||
private readonly version = 19;
|
||||
|
||||
private updatedAt = new Date(0);
|
||||
|
||||
protected log: (...args: any[]) => void;
|
||||
|
||||
private timer: ReturnType<typeof setTimeout>;
|
||||
|
||||
readonly useReady = create(() => false);
|
||||
|
||||
constructor({ name, eventType, store }: { name: Name; eventType: StreamNames; store: UseBoundStore<StoreApi<IDocumentMapStore<T>>> }) {
|
||||
this.name = name;
|
||||
this.eventType = eventType;
|
||||
this.store = store;
|
||||
|
||||
this.log = [getConfig(`debugCachedCollection-${this.name}`), getConfig('debugCachedCollection'), getConfig('debug')].includes('true')
|
||||
? console.log.bind(console, `%cCachedCollection ${this.name}`, `color: navy; font-weight: bold;`)
|
||||
: () => undefined;
|
||||
|
||||
CachedStoresManager.register(this);
|
||||
}
|
||||
|
||||
protected get eventName(): `${Name}-changed` | `${string}/${Name}-changed` {
|
||||
if (this.eventType === 'notify-user') {
|
||||
return `${getUserId()}/${this.name}-changed`;
|
||||
}
|
||||
return `${this.name}-changed`;
|
||||
}
|
||||
|
||||
protected abstract getToken(): unknown;
|
||||
|
||||
private async loadFromCache() {
|
||||
const data = await localforage.getItem<{ version: number; token: unknown; records: unknown[]; updatedAt: Date | string }>(this.name);
|
||||
|
||||
if (!data) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (data.version < this.version || data.token !== this.getToken()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (data.records.length <= 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// updatedAt may be a Date or a string depending on the used localForage backend
|
||||
if (!(data.updatedAt instanceof Date)) {
|
||||
data.updatedAt = new Date(data.updatedAt);
|
||||
}
|
||||
|
||||
if (Date.now() - data.updatedAt.getTime() >= 1000 * CachedStore.MAX_CACHE_TIME) {
|
||||
return false;
|
||||
}
|
||||
|
||||
this.log(`${data.records.length} records loaded from cache`);
|
||||
|
||||
const deserializedRecords = data.records.map((record) => this.deserializeFromCache(record)).filter(isTruthy);
|
||||
|
||||
const updatedAt = Math.max(...deserializedRecords.filter(hasUpdatedAt).map((record) => record?._updatedAt.getTime() ?? 0));
|
||||
|
||||
if (updatedAt > this.updatedAt.getTime()) {
|
||||
this.updatedAt = new Date(updatedAt);
|
||||
}
|
||||
|
||||
this.store.getState().replaceAll(deserializedRecords.filter(hasId));
|
||||
|
||||
this.updatedAt = data.updatedAt || this.updatedAt;
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
protected deserializeFromCache(record: unknown): T | undefined {
|
||||
if (typeof record !== 'object' || record === null) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return {
|
||||
...(record as unknown as T),
|
||||
...(hasUnserializedUpdatedAt(record) && {
|
||||
_updatedAt: new Date(record._updatedAt),
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
private async callLoad() {
|
||||
// TODO: workaround for bad function overload
|
||||
const data = await sdk.call(`${this.name}/get`);
|
||||
return data as unknown as U[];
|
||||
}
|
||||
|
||||
private async callSync(updatedSince: Date) {
|
||||
// TODO: workaround for bad function overload
|
||||
const data = await sdk.call(`${this.name}/get`, updatedSince);
|
||||
return data as unknown as { update: U[]; remove: U[] };
|
||||
}
|
||||
|
||||
private async loadFromServer() {
|
||||
const startTime = new Date();
|
||||
const lastTime = this.updatedAt;
|
||||
const data = await this.callLoad();
|
||||
this.log(`${data.length} records loaded from server`);
|
||||
|
||||
const newRecords = data.map((record) => {
|
||||
const mapped = this.mapRecord(record);
|
||||
|
||||
if (hasUpdatedAt(mapped) && mapped._updatedAt > this.updatedAt) {
|
||||
this.updatedAt = mapped._updatedAt;
|
||||
}
|
||||
|
||||
return mapped;
|
||||
});
|
||||
|
||||
this.store.getState().storeMany(newRecords);
|
||||
this.handleLoadedFromServer(newRecords);
|
||||
|
||||
this.updatedAt = this.updatedAt === lastTime ? startTime : this.updatedAt;
|
||||
}
|
||||
|
||||
protected mapRecord(record: U): T {
|
||||
return record as unknown as T;
|
||||
}
|
||||
|
||||
protected handleLoadedFromServer(_records: T[]): void {
|
||||
// This method can be overridden to handle records after they are loaded from the server
|
||||
}
|
||||
|
||||
protected handleSyncEvent(_action: 'removed' | 'changed', _record: T): void {
|
||||
// This method can be overridden to handle sync events
|
||||
}
|
||||
|
||||
private async loadFromServerAndPopulate() {
|
||||
await this.loadFromServer();
|
||||
await this.save();
|
||||
}
|
||||
|
||||
private save = withDebouncing({ wait: 1000 })(async () => {
|
||||
this.log('saving cache');
|
||||
await localforage.setItem(this.name, {
|
||||
updatedAt: this.updatedAt,
|
||||
version: this.version,
|
||||
token: this.getToken(),
|
||||
records: Array.from(this.store.getState().records.values()),
|
||||
});
|
||||
this.log('saving cache (done)');
|
||||
});
|
||||
|
||||
abstract clearCacheOnLogout(): void;
|
||||
|
||||
protected async clearCache() {
|
||||
this.log('clearing cache');
|
||||
await localforage.removeItem(this.name);
|
||||
this.store.getState().replaceAll([]);
|
||||
}
|
||||
|
||||
protected setupListener() {
|
||||
return sdk.stream(this.eventType, [this.eventName], (async (action: 'removed' | 'changed', record: U) => {
|
||||
this.log('record received', action, record);
|
||||
await this.handleRecordEvent(action, record);
|
||||
}) as (...args: unknown[]) => void);
|
||||
}
|
||||
|
||||
protected async handleRecordEvent(action: 'removed' | 'changed', record: U) {
|
||||
const newRecord = this.mapRecord(record);
|
||||
|
||||
if (action === 'removed') {
|
||||
this.store.getState().delete(newRecord._id);
|
||||
} else {
|
||||
this.store.getState().store(newRecord);
|
||||
}
|
||||
|
||||
await this.save();
|
||||
}
|
||||
|
||||
private trySync(delay = 10) {
|
||||
clearTimeout(this.timer);
|
||||
// Wait for an empty queue to load data again and sync
|
||||
this.timer = setTimeout(async () => {
|
||||
if (!(await this.sync())) {
|
||||
return this.trySync(delay);
|
||||
}
|
||||
await this.save();
|
||||
}, delay);
|
||||
}
|
||||
|
||||
protected async sync() {
|
||||
if (!this.updatedAt || this.updatedAt.getTime() === 0 || Meteor.connection._outstandingMethodBlocks.length !== 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const startTime = new Date();
|
||||
const lastTime = this.updatedAt;
|
||||
|
||||
this.log(`syncing from ${this.updatedAt}`);
|
||||
|
||||
const data = await this.callSync(this.updatedAt);
|
||||
const changes = [];
|
||||
|
||||
if (data.update && data.update.length > 0) {
|
||||
this.log(`${data.update.length} records updated in sync`);
|
||||
for (const record of data.update) {
|
||||
const newRecord = this.mapRecord(record);
|
||||
|
||||
const actionTime = hasUpdatedAt(newRecord) ? newRecord._updatedAt : startTime;
|
||||
changes.push({
|
||||
action: () => {
|
||||
this.store.getState().store(newRecord);
|
||||
if (actionTime > this.updatedAt) {
|
||||
this.updatedAt = actionTime;
|
||||
}
|
||||
this.handleSyncEvent('changed', newRecord);
|
||||
},
|
||||
timestamp: actionTime.getTime(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (data.remove && data.remove.length > 0) {
|
||||
this.log(`${data.remove.length} records removed in sync`);
|
||||
for (const record of data.remove) {
|
||||
const newRecord = this.mapRecord(record);
|
||||
|
||||
if (!hasDeletedAt(newRecord)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const actionTime = newRecord._deletedAt;
|
||||
changes.push({
|
||||
action: () => {
|
||||
this.store.getState().delete(newRecord._id);
|
||||
if (actionTime > this.updatedAt) {
|
||||
this.updatedAt = actionTime;
|
||||
}
|
||||
this.handleSyncEvent('removed', newRecord);
|
||||
},
|
||||
timestamp: actionTime.getTime(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
changes
|
||||
.sort((a, b) => a.timestamp - b.timestamp)
|
||||
.forEach((c) => {
|
||||
c.action();
|
||||
});
|
||||
|
||||
this.updatedAt = this.updatedAt === lastTime ? startTime : this.updatedAt;
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private listenerUnsubscriber: (() => void) | undefined;
|
||||
|
||||
private async performInitialization() {
|
||||
if (await this.loadFromCache()) {
|
||||
this.trySync();
|
||||
} else {
|
||||
await this.loadFromServerAndPopulate();
|
||||
}
|
||||
|
||||
this.reconnectionUnsubscribe?.();
|
||||
const sdk = getDdpSdk();
|
||||
let wentOffline = sdk.connection.status !== 'connected';
|
||||
this.reconnectionUnsubscribe = sdk.connection.on('connection', () => {
|
||||
if (sdk.connection.status !== 'connected') {
|
||||
wentOffline = true;
|
||||
return;
|
||||
}
|
||||
if (wentOffline) {
|
||||
wentOffline = false;
|
||||
this.trySync();
|
||||
}
|
||||
});
|
||||
|
||||
const subscription = this.setupListener();
|
||||
this.listenerUnsubscriber = () => {
|
||||
subscription.stop();
|
||||
this.listenerUnsubscriber = undefined;
|
||||
};
|
||||
}
|
||||
|
||||
private initializationPromise: Promise<void> | undefined;
|
||||
|
||||
init() {
|
||||
if (this.initializationPromise) {
|
||||
return this.initializationPromise;
|
||||
}
|
||||
|
||||
this.initializationPromise = this.performInitialization()
|
||||
.catch(console.error)
|
||||
.finally(() => {
|
||||
this.initializationPromise = undefined;
|
||||
this.setReady(true);
|
||||
});
|
||||
|
||||
return this.initializationPromise;
|
||||
}
|
||||
|
||||
async release() {
|
||||
if (this.initializationPromise) {
|
||||
await this.initializationPromise;
|
||||
}
|
||||
|
||||
this.listenerUnsubscriber?.();
|
||||
this.setReady(false);
|
||||
}
|
||||
|
||||
private reconnectionUnsubscribe: (() => void) | undefined;
|
||||
|
||||
setReady(ready: boolean) {
|
||||
this.useReady.setState(ready);
|
||||
}
|
||||
}
|
||||
|
||||
export class PublicCachedStore<T extends IRocketChatRecord, U = T> extends CachedStore<T, U> {
|
||||
protected override getToken() {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
override clearCacheOnLogout() {
|
||||
// do nothing
|
||||
}
|
||||
}
|
||||
|
||||
export class PrivateCachedStore<T extends IRocketChatRecord, U = T> extends CachedStore<T, U> {
|
||||
protected override getToken() {
|
||||
return getStoredItem(STORAGE_KEYS.LOGIN_TOKEN);
|
||||
}
|
||||
|
||||
override clearCacheOnLogout() {
|
||||
void this.clearCache();
|
||||
}
|
||||
|
||||
listen() {
|
||||
if (process.env.NODE_ENV === 'test') {
|
||||
return;
|
||||
}
|
||||
|
||||
onLoggedIn(() => {
|
||||
void this.init();
|
||||
});
|
||||
|
||||
getDdpSdk().account.onLogout(() => {
|
||||
this.release();
|
||||
});
|
||||
}
|
||||
}
|
||||
25
apps/meteor/client/lib/cachedStores/CachedStoresManager.ts
Normal file
25
apps/meteor/client/lib/cachedStores/CachedStoresManager.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import type { IWithManageableCache } from './CachedStore';
|
||||
import { getDdpSdk } from '../sdk/ddpSdk';
|
||||
|
||||
class CachedStoresManager {
|
||||
private items = new Set<IWithManageableCache>();
|
||||
|
||||
register(cachedCollection: IWithManageableCache) {
|
||||
this.items.add(cachedCollection);
|
||||
}
|
||||
|
||||
clearAllCachesOnLogout() {
|
||||
for (const item of this.items) {
|
||||
item.clearCacheOnLogout();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const instance = new CachedStoresManager();
|
||||
|
||||
getDdpSdk().account.onLogout(() => instance.clearAllCachesOnLogout());
|
||||
|
||||
export {
|
||||
/** @deprecated */
|
||||
instance as CachedStoresManager,
|
||||
};
|
||||
308
apps/meteor/client/lib/cachedStores/DocumentMapStore.ts
Normal file
308
apps/meteor/client/lib/cachedStores/DocumentMapStore.ts
Normal file
@@ -0,0 +1,308 @@
|
||||
import { create } from 'zustand';
|
||||
|
||||
export interface IDocumentMapStore<T extends { _id: string }> {
|
||||
readonly records: ReadonlyMap<T['_id'], T>;
|
||||
/**
|
||||
* Checks if a document with the given _id exists in the store.
|
||||
*
|
||||
* @param _id - The _id of the document to check.
|
||||
* @returns true if the document exists, false otherwise.
|
||||
*/
|
||||
has(_id: T['_id']): boolean;
|
||||
/**
|
||||
* Retrieves a document by its _id.
|
||||
*
|
||||
* @param _id - The _id of the document to retrieve.
|
||||
* @returns The document if found, or undefined if not found.
|
||||
*/
|
||||
get(_id: T['_id']): T | undefined;
|
||||
/**
|
||||
* Checks if any document in the store satisfies the given predicate.
|
||||
*
|
||||
* @param predicate - A function that takes a document and returns true if it matches the condition.
|
||||
* @returns true if at least one document matches the predicate, false otherwise.
|
||||
*/
|
||||
some(predicate: (record: T) => boolean): boolean;
|
||||
/**
|
||||
* Finds a document that satisfies the given predicate.
|
||||
*
|
||||
* @param predicate - A function that takes a document and returns true if it matches the condition.
|
||||
* @returns The first document that matches the predicate, or undefined if no document matches.
|
||||
*/
|
||||
find<U extends T>(predicate: (record: T) => record is U): U | undefined;
|
||||
/**
|
||||
* Finds a document that satisfies the given predicate.
|
||||
*
|
||||
* @param predicate - A function that takes a document and returns true if it matches the condition.
|
||||
* @returns The first document that matches the predicate, or undefined if no document matches.
|
||||
*/
|
||||
find(predicate: (record: T) => boolean): T | undefined;
|
||||
/**
|
||||
* Finds the first document that satisfies the given predicate, using a comparator to determine the best match.
|
||||
*
|
||||
* Usually the "best" document is the first of a ordered set, but it can be any criteria defined by the comparator.
|
||||
*
|
||||
* @param predicate - A function that takes a document and returns true if it matches the condition.
|
||||
* @param comparator - A function that compares two documents and returns a negative number if the first is better, zero if they are equal, or a positive number if the second is better.
|
||||
* @returns The best matching document according to the predicate and comparator, or undefined if no document matches.
|
||||
*/
|
||||
findFirst<U extends T>(predicate: (record: T) => record is U, comparator: (a: T, b: T) => number): U | undefined;
|
||||
/**
|
||||
* Finds the first document that satisfies the given predicate, using a comparator to determine the best match.
|
||||
*
|
||||
* Usually the "best" document is the first of a ordered set, but it can be any criteria defined by the comparator.
|
||||
*
|
||||
* @param predicate - A function that takes a document and returns true if it matches the condition.
|
||||
* @param comparator - A function that compares two documents and returns a negative number if the first is better, zero if they are equal, or a positive number if the second is better.
|
||||
* @returns The best matching document according to the predicate and comparator, or undefined if no document matches.
|
||||
*/
|
||||
findFirst(predicate: (record: T) => boolean, comparator: (a: T, b: T) => number): T | undefined;
|
||||
/**
|
||||
* Filters documents in the store based on a predicate.
|
||||
*
|
||||
* @param predicate - A function that takes a document and returns true if it matches the condition.
|
||||
* @returns An array of documents that match the predicate.
|
||||
*/
|
||||
filter<U extends T>(predicate: (record: T) => record is U): U[];
|
||||
/**
|
||||
* Filters documents in the store based on a predicate.
|
||||
*
|
||||
* @param predicate - A function that takes a document and returns true if it matches the condition.
|
||||
* @returns An array of documents that match the predicate.
|
||||
*/
|
||||
filter(predicate: (record: T) => boolean): T[];
|
||||
/**
|
||||
* Creates an index of documents by a specified key.
|
||||
*
|
||||
* @param key - The key to index the documents by.
|
||||
* @returns A Map where the keys are the values of the specified key in the documents, and the values are the documents themselves.
|
||||
*/
|
||||
indexBy<TKey extends keyof T>(key: TKey): ReadonlyMap<T[TKey], T>;
|
||||
/**
|
||||
* Replaces all documents in the store with the provided records.
|
||||
*
|
||||
* @param records - An array of documents to replace the current records in the store.
|
||||
*/
|
||||
replaceAll(records: T[]): void;
|
||||
/**
|
||||
* Stores a single document in the store.
|
||||
*
|
||||
* @param doc - The document to store.
|
||||
*/
|
||||
store(doc: T): void;
|
||||
/**
|
||||
* Stores multiple documents in the store.
|
||||
*
|
||||
* @param docs - An iterable of documents to store.
|
||||
*/
|
||||
storeMany(docs: Iterable<T>): void;
|
||||
/**
|
||||
* Deletes a document from the store by its _id.
|
||||
*
|
||||
* @param _id - The _id of the document to delete.
|
||||
*/
|
||||
delete(_id: T['_id']): void;
|
||||
/**
|
||||
* Updates documents in the store that match a predicate.
|
||||
*
|
||||
* @param predicate - A function that takes a document and returns true if it matches the condition.
|
||||
* @param modifier - A function that takes a document and returns the modified document.
|
||||
* @returns void
|
||||
*/
|
||||
update<U extends T>(predicate: (record: T) => record is U, modifier: (record: U) => U): void;
|
||||
/**
|
||||
* Updates documents in the store that match a predicate.
|
||||
*
|
||||
* @param predicate - A function that takes a document and returns true if it matches the condition.
|
||||
* @param modifier - A function that takes a document and returns the modified document.
|
||||
* @returns void
|
||||
*/
|
||||
update(predicate: (record: T) => boolean, modifier: (record: T) => T): void;
|
||||
/**
|
||||
* Asynchronously updates documents in the store that match a predicate.
|
||||
*
|
||||
* @param predicate - A function that takes a document and returns true if it matches the condition.
|
||||
* @param modifier - A function that takes a document and returns a Promise that resolves to the modified document.
|
||||
* @returns void
|
||||
*/
|
||||
updateAsync<U extends T>(predicate: (record: T) => record is U, modifier: (record: U) => Promise<U>): Promise<void>;
|
||||
/**
|
||||
* Asynchronously updates documents in the store that match a predicate.
|
||||
*
|
||||
* @param predicate - A function that takes a document and returns true if it matches the condition.
|
||||
* @param modifier - A function that takes a document and returns a Promise that resolves to the modified document.
|
||||
* @returns void
|
||||
*/
|
||||
updateAsync(predicate: (record: T) => boolean, modifier: (record: T) => Promise<T>): Promise<void>;
|
||||
/**
|
||||
* Removes documents from the store that match a predicate.
|
||||
*
|
||||
* @param predicate - A function that takes a document and returns true if it matches the condition.
|
||||
*/
|
||||
remove(predicate: (record: T) => boolean): void;
|
||||
count(predicate: (record: T) => boolean): number;
|
||||
}
|
||||
|
||||
export interface IDocumentMapStoreHooks<T extends { _id: string }> {
|
||||
/**
|
||||
* Callback invoked when a document is stored, updated or deleted.
|
||||
*
|
||||
* This is useful to recompute Minimongo queries that depend on the changed documents.
|
||||
* @deprecated prefer subscribing to the store
|
||||
*/
|
||||
onInvalidate?: (...docs: T[]) => void;
|
||||
/**
|
||||
* Callback invoked when all documents are replaced in the store.
|
||||
*
|
||||
* This is useful to recompute Minimongo queries that depend on the changed documents.
|
||||
* @deprecated prefer subscribing to the store
|
||||
*/
|
||||
onInvalidateAll?: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Factory function to create a Zustand store that holds a map of documents.
|
||||
*
|
||||
* @param options - Optional callbacks to handle invalidation of documents.
|
||||
* @returns the Zustand store with methods to manage the document map.
|
||||
*/
|
||||
export const createDocumentMapStore = <T extends { _id: string }>({ onInvalidate, onInvalidateAll }: IDocumentMapStoreHooks<T> = {}) =>
|
||||
create<IDocumentMapStore<T>>()((set, get) => ({
|
||||
records: new Map(),
|
||||
has: (id: T['_id']) => get().records.has(id),
|
||||
get: (id: T['_id']) => get().records.get(id),
|
||||
some: (predicate: (record: T) => boolean) => {
|
||||
for (const record of get().records.values()) {
|
||||
if (predicate(record)) return true;
|
||||
}
|
||||
return false;
|
||||
},
|
||||
find: (predicate: (record: T) => boolean) => {
|
||||
for (const record of get().records.values()) {
|
||||
if (predicate(record)) return record;
|
||||
}
|
||||
return undefined;
|
||||
},
|
||||
findFirst: (predicate: (record: T) => boolean, comparator: (a: T, b: T) => number) => {
|
||||
let best: T | undefined;
|
||||
for (const record of get().records.values()) {
|
||||
if (!predicate(record)) continue;
|
||||
if (best === undefined || comparator(record, best) < 0) {
|
||||
best = record;
|
||||
}
|
||||
}
|
||||
return best;
|
||||
},
|
||||
filter: (predicate: (record: T) => boolean) => {
|
||||
const results: T[] = [];
|
||||
for (const record of get().records.values()) {
|
||||
if (predicate(record)) {
|
||||
results.push(record);
|
||||
}
|
||||
}
|
||||
return results;
|
||||
},
|
||||
indexBy: <TKey extends keyof T>(key: TKey) => {
|
||||
const index = new Map<T[TKey], T>();
|
||||
|
||||
for (const record of get().records.values()) {
|
||||
const keyValue = record[key];
|
||||
index.set(keyValue, record);
|
||||
}
|
||||
|
||||
return index;
|
||||
},
|
||||
replaceAll: (records: T[]) => {
|
||||
set({ records: new Map(records.map((record) => [record._id, record])) });
|
||||
onInvalidateAll?.();
|
||||
},
|
||||
store: (doc) => {
|
||||
set((state) => ({ records: new Map(state.records).set(doc._id, doc) }));
|
||||
onInvalidate?.(doc);
|
||||
},
|
||||
storeMany: (docs) => {
|
||||
set((state) => {
|
||||
const records = new Map(state.records);
|
||||
|
||||
for (const doc of docs) {
|
||||
records.set(doc._id, doc);
|
||||
}
|
||||
|
||||
return { records };
|
||||
});
|
||||
onInvalidate?.(...docs);
|
||||
},
|
||||
delete: (_id) => {
|
||||
const affected: T[] = [];
|
||||
set((state) => {
|
||||
const records = new Map(state.records);
|
||||
if (onInvalidate) affected.push(state.records.get(_id)!);
|
||||
records.delete(_id);
|
||||
return { records };
|
||||
});
|
||||
onInvalidate?.(...affected);
|
||||
},
|
||||
update: (predicate: (record: T) => boolean, modifier: (record: T) => T) => {
|
||||
const affected: T[] = [];
|
||||
|
||||
set((state) => {
|
||||
const records = new Map<T['_id'], T>();
|
||||
for (const record of state.records.values()) {
|
||||
if (predicate(record)) {
|
||||
const newRecord = modifier(record);
|
||||
records.set(record._id, newRecord);
|
||||
if (onInvalidate) affected.push(newRecord);
|
||||
} else {
|
||||
records.set(record._id, record);
|
||||
}
|
||||
}
|
||||
|
||||
return { records };
|
||||
});
|
||||
onInvalidate?.(...affected);
|
||||
},
|
||||
updateAsync: async (predicate: (record: T) => boolean, modifier: (record: T) => Promise<T>) => {
|
||||
const affected: T[] = [];
|
||||
|
||||
const records = new Map<T['_id'], T>();
|
||||
|
||||
for await (const record of get().records.values()) {
|
||||
if (predicate(record)) {
|
||||
const newRecord = await modifier(record);
|
||||
records.set(record._id, newRecord);
|
||||
if (onInvalidate) affected.push(newRecord);
|
||||
} else {
|
||||
records.set(record._id, record);
|
||||
}
|
||||
}
|
||||
|
||||
set({ records });
|
||||
onInvalidate?.(...affected);
|
||||
},
|
||||
remove: (predicate: (record: T) => boolean) => {
|
||||
const affected: T[] = [];
|
||||
|
||||
set((state) => {
|
||||
const records = new Map<T['_id'], T>();
|
||||
for (const record of state.records.values()) {
|
||||
if (predicate(record)) {
|
||||
if (onInvalidate) affected.push(record);
|
||||
continue;
|
||||
}
|
||||
records.set(record._id, record);
|
||||
}
|
||||
|
||||
return { records };
|
||||
});
|
||||
onInvalidate?.(...affected);
|
||||
},
|
||||
count: (predicate: (record: T) => boolean) => {
|
||||
let results = 0;
|
||||
for (const record of get().records.values()) {
|
||||
if (predicate(record)) {
|
||||
results += 1;
|
||||
}
|
||||
}
|
||||
return results;
|
||||
},
|
||||
}));
|
||||
55
apps/meteor/client/lib/cachedStores/applyQueryOptions.ts
Normal file
55
apps/meteor/client/lib/cachedStores/applyQueryOptions.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
import type { FindOptions } from '@rocket.chat/ui-contexts';
|
||||
|
||||
import { pipe } from './pipe';
|
||||
|
||||
type OriginalStructure = FindOptions['sort'];
|
||||
|
||||
type SortField = 'lm' | 'lowerCaseFName' | 'lowerCaseName';
|
||||
type SortDirection = -1 | 1;
|
||||
|
||||
type SortObject = {
|
||||
field: SortField;
|
||||
direction: SortDirection;
|
||||
}[];
|
||||
|
||||
/**
|
||||
* Converts a MongoDB-style sort structure to a sort object.
|
||||
*/
|
||||
const convertSort = (original: OriginalStructure): SortObject => {
|
||||
const convertedSort: SortObject = [];
|
||||
|
||||
if (!original) {
|
||||
return convertedSort;
|
||||
}
|
||||
Object.keys(original as Record<string, any>).forEach((key) => {
|
||||
const direction = original[key as keyof OriginalStructure];
|
||||
|
||||
if (direction === -1 || direction === 1) {
|
||||
convertedSort.push({
|
||||
field: key as SortField,
|
||||
direction,
|
||||
});
|
||||
}
|
||||
});
|
||||
return convertedSort;
|
||||
};
|
||||
|
||||
export const applyQueryOptions = <T extends Record<string, any>>(records: T[], options: FindOptions): T[] => {
|
||||
let currentPipeline = pipe(records);
|
||||
if (options.sort) {
|
||||
const sortObj = convertSort(options.sort);
|
||||
for (let i = sortObj.length - 1; i >= 0; i--) {
|
||||
const { field, direction } = sortObj[i];
|
||||
currentPipeline = currentPipeline.sortByField(field, direction);
|
||||
}
|
||||
}
|
||||
if (options.skip) {
|
||||
currentPipeline = currentPipeline.slice(options.skip, records.length);
|
||||
}
|
||||
if (options.limit !== undefined) {
|
||||
// If skip was applied, limit will be applied on the already skipped array
|
||||
// If no skip, it will be applied from the beginning.
|
||||
currentPipeline = currentPipeline.slice(0, options.limit);
|
||||
}
|
||||
return currentPipeline.apply();
|
||||
};
|
||||
14
apps/meteor/client/lib/cachedStores/createGlobalStore.ts
Normal file
14
apps/meteor/client/lib/cachedStores/createGlobalStore.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import type { StoreApi, UseBoundStore } from 'zustand';
|
||||
|
||||
import type { IDocumentMapStore } from './DocumentMapStore';
|
||||
|
||||
export const createGlobalStore = <T extends { _id: string }, U>(store: UseBoundStore<StoreApi<IDocumentMapStore<T>>>, extension?: U) =>
|
||||
Object.assign(
|
||||
{
|
||||
use: store,
|
||||
get state(): IDocumentMapStore<T> {
|
||||
return this.use.getState();
|
||||
},
|
||||
} as const,
|
||||
extension,
|
||||
);
|
||||
213
apps/meteor/client/lib/cachedStores/pipe.spec.ts
Normal file
213
apps/meteor/client/lib/cachedStores/pipe.spec.ts
Normal file
@@ -0,0 +1,213 @@
|
||||
import { pipe } from './pipe';
|
||||
|
||||
describe('pipe', () => {
|
||||
interface ITestData {
|
||||
_id: number;
|
||||
name: string;
|
||||
ts: number;
|
||||
date: Date;
|
||||
}
|
||||
|
||||
const sampleData: ITestData[] = [
|
||||
{ _id: 1, name: 'Alice', ts: 30, date: new Date('2025-07-16T01:00:00.000Z') },
|
||||
{ _id: 2, name: 'Charlie', ts: 25, date: new Date('2025-07-16T02:00:00.000Z') },
|
||||
{ _id: 3, name: 'Bob', ts: 35, date: new Date('2025-07-16T03:00:00.000Z') },
|
||||
{ _id: 4, name: 'David', ts: 20, date: new Date('2025-07-16T04:00:00.000Z') },
|
||||
{ _id: 5, name: 'Eve', ts: 40, date: new Date('2025-07-16T05:00:00.000Z') },
|
||||
];
|
||||
|
||||
describe('missing fields', () => {
|
||||
it('should let the rows with missing fields at the end', () => {
|
||||
const arr = [...sampleData];
|
||||
arr.splice(Math.floor(Math.random() * arr.length), 0, { _id: 6, name: 'A', ts: 50 } as ITestData);
|
||||
|
||||
const result = pipe<ITestData>().sortByField('date', 1).apply(arr);
|
||||
expect(result).toEqual([...sampleData, { _id: 6, name: 'A', ts: 50 }]);
|
||||
});
|
||||
it('should sort by fallback fields if a field is missing', () => {
|
||||
// adds randomly an row if missing date
|
||||
const arr = [...sampleData];
|
||||
arr.splice(Math.floor(Math.random() * arr.length), 0, { _id: 6, name: 'A', ts: 50 } as ITestData);
|
||||
arr.splice(Math.floor(Math.random() * arr.length), 0, { _id: 7, name: 'B', ts: 50 } as ITestData);
|
||||
arr.splice(Math.floor(Math.random() * arr.length), 0, { _id: 8, name: 'C', ts: 50 } as ITestData);
|
||||
|
||||
const result = pipe<ITestData>().sortByField('date', 1, ['name']).apply(arr);
|
||||
expect(result).toEqual([...sampleData, { _id: 6, name: 'A', ts: 50 }, { _id: 7, name: 'B', ts: 50 }, { _id: 8, name: 'C', ts: 50 }]);
|
||||
});
|
||||
it('should sort by fallback fields if a field is missing with second signature', () => {
|
||||
// adds randomly an row if missing date
|
||||
const arr = [...sampleData];
|
||||
arr.splice(Math.floor(Math.random() * arr.length), 0, { _id: 6, name: 'A', ts: 50 } as ITestData);
|
||||
arr.splice(Math.floor(Math.random() * arr.length), 0, { _id: 7, name: 'B', ts: 50 } as ITestData);
|
||||
arr.splice(Math.floor(Math.random() * arr.length), 0, { _id: 8, name: 'C', ts: 50 } as ITestData);
|
||||
|
||||
const result = pipe<ITestData>().sortByField(['date', 'name'], 1).apply(arr);
|
||||
expect(result).toEqual([...sampleData, { _id: 6, name: 'A', ts: 50 }, { _id: 7, name: 'B', ts: 50 }, { _id: 8, name: 'C', ts: 50 }]);
|
||||
});
|
||||
});
|
||||
|
||||
it('should correctly slice the array with skip and limit', () => {
|
||||
const result = pipe<ITestData>().slice(1, 2).apply(sampleData);
|
||||
expect(result).toEqual([
|
||||
{ _id: 2, name: 'Charlie', ts: 25, date: new Date('2025-07-16T02:00:00.000Z') },
|
||||
{ _id: 3, name: 'Bob', ts: 35, date: new Date('2025-07-16T03:00:00.000Z') },
|
||||
]);
|
||||
});
|
||||
|
||||
it('should correctly slice the array with only limit', () => {
|
||||
const result = pipe<ITestData>().slice(0, 3).apply(sampleData);
|
||||
expect(result).toEqual([
|
||||
{ _id: 1, name: 'Alice', ts: 30, date: new Date('2025-07-16T01:00:00.000Z') },
|
||||
{ _id: 2, name: 'Charlie', ts: 25, date: new Date('2025-07-16T02:00:00.000Z') },
|
||||
{ _id: 3, name: 'Bob', ts: 35, date: new Date('2025-07-16T03:00:00.000Z') },
|
||||
]);
|
||||
});
|
||||
|
||||
it('should return an empty array if skip is greater than array length', () => {
|
||||
const result = pipe<ITestData>().slice(10, 2).apply(sampleData);
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it('should return remaining elements if limit is greater than available', () => {
|
||||
const result = pipe<ITestData>().slice(3, 10).apply(sampleData);
|
||||
expect(result).toEqual([
|
||||
{ _id: 4, name: 'David', ts: 20, date: new Date('2025-07-16T04:00:00.000Z') },
|
||||
{ _id: 5, name: 'Eve', ts: 40, date: new Date('2025-07-16T05:00:00.000Z') },
|
||||
]);
|
||||
});
|
||||
|
||||
it('should sort the array by a string field in ascending order', () => {
|
||||
const result = pipe<ITestData>().sortByField('name', 1).apply(sampleData);
|
||||
expect(result).toEqual([
|
||||
{ _id: 1, name: 'Alice', ts: 30, date: new Date('2025-07-16T01:00:00.000Z') },
|
||||
{ _id: 3, name: 'Bob', ts: 35, date: new Date('2025-07-16T03:00:00.000Z') },
|
||||
{ _id: 2, name: 'Charlie', ts: 25, date: new Date('2025-07-16T02:00:00.000Z') },
|
||||
{ _id: 4, name: 'David', ts: 20, date: new Date('2025-07-16T04:00:00.000Z') },
|
||||
{ _id: 5, name: 'Eve', ts: 40, date: new Date('2025-07-16T05:00:00.000Z') },
|
||||
]);
|
||||
});
|
||||
|
||||
it('should sort the array by a date field in ascending order', () => {
|
||||
const result = pipe<ITestData>()
|
||||
.sortByField('date', 1)
|
||||
.apply([...sampleData].sort(() => Math.random() - 0.5));
|
||||
expect(result).toEqual(sampleData);
|
||||
});
|
||||
|
||||
it('should sort the array by a string field in descending order', () => {
|
||||
const result = pipe<ITestData>().sortByField('name', -1).apply(sampleData);
|
||||
expect(result).toEqual([
|
||||
{ _id: 5, name: 'Eve', ts: 40, date: new Date('2025-07-16T05:00:00.000Z') },
|
||||
{ _id: 4, name: 'David', ts: 20, date: new Date('2025-07-16T04:00:00.000Z') },
|
||||
{ _id: 2, name: 'Charlie', ts: 25, date: new Date('2025-07-16T02:00:00.000Z') },
|
||||
{ _id: 3, name: 'Bob', ts: 35, date: new Date('2025-07-16T03:00:00.000Z') },
|
||||
{ _id: 1, name: 'Alice', ts: 30, date: new Date('2025-07-16T01:00:00.000Z') },
|
||||
]);
|
||||
});
|
||||
|
||||
it('should sort the array by a number field in ascending order', () => {
|
||||
const result = pipe<ITestData>().sortByField('ts', 1).apply(sampleData);
|
||||
expect(result).toEqual([
|
||||
{ _id: 4, name: 'David', ts: 20, date: new Date('2025-07-16T04:00:00.000Z') },
|
||||
{ _id: 2, name: 'Charlie', ts: 25, date: new Date('2025-07-16T02:00:00.000Z') },
|
||||
{ _id: 1, name: 'Alice', ts: 30, date: new Date('2025-07-16T01:00:00.000Z') },
|
||||
{ _id: 3, name: 'Bob', ts: 35, date: new Date('2025-07-16T03:00:00.000Z') },
|
||||
{ _id: 5, name: 'Eve', ts: 40, date: new Date('2025-07-16T05:00:00.000Z') },
|
||||
]);
|
||||
});
|
||||
|
||||
it('should sort the array by a number field in descending order', () => {
|
||||
const result = pipe<ITestData>().sortByField('ts', -1).apply(sampleData);
|
||||
expect(result).toEqual([
|
||||
{ _id: 5, name: 'Eve', ts: 40, date: new Date('2025-07-16T05:00:00.000Z') },
|
||||
{ _id: 3, name: 'Bob', ts: 35, date: new Date('2025-07-16T03:00:00.000Z') },
|
||||
{ _id: 1, name: 'Alice', ts: 30, date: new Date('2025-07-16T01:00:00.000Z') },
|
||||
{ _id: 2, name: 'Charlie', ts: 25, date: new Date('2025-07-16T02:00:00.000Z') },
|
||||
{ _id: 4, name: 'David', ts: 20, date: new Date('2025-07-16T04:00:00.000Z') },
|
||||
]);
|
||||
});
|
||||
|
||||
it('should apply chained operations correctly (sort then slice)', () => {
|
||||
const result = pipe<ITestData>().sortByField('ts').slice(1, 3).apply(sampleData);
|
||||
expect(result).toEqual([
|
||||
{ _id: 2, name: 'Charlie', ts: 25, date: new Date('2025-07-16T02:00:00.000Z') },
|
||||
{ _id: 1, name: 'Alice', ts: 30, date: new Date('2025-07-16T01:00:00.000Z') },
|
||||
{ _id: 3, name: 'Bob', ts: 35, date: new Date('2025-07-16T03:00:00.000Z') },
|
||||
]);
|
||||
});
|
||||
|
||||
it('should apply chained operations correctly (slice then sort)', () => {
|
||||
const result = pipe<ITestData>().slice(0, 4).sortByField('name', -1).apply(sampleData);
|
||||
expect(result).toEqual([
|
||||
{ _id: 4, name: 'David', ts: 20, date: new Date('2025-07-16T04:00:00.000Z') },
|
||||
{ _id: 2, name: 'Charlie', ts: 25, date: new Date('2025-07-16T02:00:00.000Z') },
|
||||
{ _id: 3, name: 'Bob', ts: 35, date: new Date('2025-07-16T03:00:00.000Z') },
|
||||
{ _id: 1, name: 'Alice', ts: 30, date: new Date('2025-07-16T01:00:00.000Z') },
|
||||
]);
|
||||
});
|
||||
|
||||
it('should return an empty array when applying to an empty array', () => {
|
||||
const result = pipe<ITestData>().slice(0, 1).apply([]);
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it('should combine two pipelines using the pipe method', () => {
|
||||
const pipeline1 = pipe<ITestData>().sortByField('name');
|
||||
const pipeline2 = pipe<ITestData>().slice(1, 2);
|
||||
const combinedPipeline = pipe<ITestData>().pipe(pipeline1).pipe(pipeline2);
|
||||
|
||||
const result = combinedPipeline.apply(sampleData);
|
||||
expect(result).toEqual([
|
||||
{ _id: 3, name: 'Bob', ts: 35, date: new Date('2025-07-16T03:00:00.000Z') },
|
||||
{ _id: 2, name: 'Charlie', ts: 25, date: new Date('2025-07-16T02:00:00.000Z') },
|
||||
]);
|
||||
});
|
||||
|
||||
it('should combine pipelines and maintain initial operations', () => {
|
||||
const pipeline1 = pipe<ITestData>().sortByField('ts');
|
||||
const pipeline2 = pipe<ITestData>().slice(2, 2);
|
||||
const combinedPipeline = pipeline1.pipe(pipeline2);
|
||||
|
||||
const result = combinedPipeline.apply(sampleData);
|
||||
// Expected order after ts sort: David, Charlie, Alice, Bob, Eve
|
||||
// Slice(2,2) should take: Alice, Bob
|
||||
expect(result).toEqual([
|
||||
{ _id: 1, name: 'Alice', ts: 30, date: new Date('2025-07-16T01:00:00.000Z') },
|
||||
{ _id: 3, name: 'Bob', ts: 35, date: new Date('2025-07-16T03:00:00.000Z') },
|
||||
]);
|
||||
});
|
||||
|
||||
it('should handle non-existent fields in sortByField without throwing and without changing the order', () => {
|
||||
const result = pipe<ITestData>()
|
||||
.sortByField('nonExistentField' as keyof ITestData)
|
||||
.apply(sampleData);
|
||||
expect(result).toEqual(sampleData);
|
||||
});
|
||||
|
||||
it('should return the original array if no operations are applied', () => {
|
||||
const result = pipe<ITestData>().apply(sampleData);
|
||||
expect(result).toEqual(sampleData);
|
||||
});
|
||||
|
||||
it('should not modify the original array', () => {
|
||||
const originalData = [...sampleData];
|
||||
const result = pipe<ITestData>().sortByField('ts').apply(sampleData);
|
||||
expect(sampleData).toEqual(originalData);
|
||||
expect(result).not.toEqual(originalData);
|
||||
});
|
||||
|
||||
describe('inferred type by initial data', () => {
|
||||
it('should return the original array if no operations are applied', () => {
|
||||
const result = pipe(sampleData).apply();
|
||||
expect(result).toEqual(sampleData);
|
||||
});
|
||||
|
||||
it('should slice the array with skip and limit', () => {
|
||||
const result = pipe(sampleData).slice(1, 2).apply();
|
||||
expect(result).toEqual([
|
||||
{ _id: 2, name: 'Charlie', ts: 25, date: new Date('2025-07-16T02:00:00.000Z') },
|
||||
{ _id: 3, name: 'Bob', ts: 35, date: new Date('2025-07-16T03:00:00.000Z') },
|
||||
]);
|
||||
});
|
||||
});
|
||||
});
|
||||
90
apps/meteor/client/lib/cachedStores/pipe.ts
Normal file
90
apps/meteor/client/lib/cachedStores/pipe.ts
Normal file
@@ -0,0 +1,90 @@
|
||||
interface IPipeReturn<D> {
|
||||
slice(skip: number, limit: number): IPipeReturn<D>;
|
||||
sortByField(fieldName: keyof D, direction?: 1 | -1, fallback?: Array<keyof D>): IPipeReturn<D>;
|
||||
sortByField(fieldName: Array<keyof D>, direction?: 1 | -1, fallback?: Array<keyof D>): IPipeReturn<D>;
|
||||
apply(): D[];
|
||||
pipe(p: IPipeReturn<D>): IPipeReturn<D>;
|
||||
}
|
||||
|
||||
interface IPipeReturnNoInitialData<D> {
|
||||
slice(skip: number, limit: number): IPipeReturnNoInitialData<D>;
|
||||
sortByField(fieldName: keyof D, direction?: 1 | -1, fallback?: Array<keyof D>): IPipeReturnNoInitialData<D>;
|
||||
sortByField(fieldName: Array<keyof D>, direction?: 1 | -1): IPipeReturnNoInitialData<D>;
|
||||
apply(arg: D[]): D[];
|
||||
pipe(p: IPipeReturnNoInitialData<D>): IPipeReturnNoInitialData<D>;
|
||||
}
|
||||
|
||||
type PipeFunction<D> = (arg: D[]) => D[];
|
||||
|
||||
const merge =
|
||||
<D>(fn: PipeFunction<D>, inner: PipeFunction<D>): PipeFunction<D> =>
|
||||
(args) =>
|
||||
fn(inner(args));
|
||||
|
||||
export function pipe<D>(): IPipeReturnNoInitialData<D>;
|
||||
export function pipe<D>(initialData: D[]): IPipeReturn<D>;
|
||||
export function pipe<D>(initialData: D[], acc: PipeFunction<D>): IPipeReturn<D>;
|
||||
|
||||
export function pipe<D>(
|
||||
initialData?: any,
|
||||
acc: PipeFunction<D> = (arg) => [...arg],
|
||||
): typeof initialData extends undefined ? IPipeReturnNoInitialData<D> : IPipeReturn<D> {
|
||||
return {
|
||||
slice(skip = 0, limit: number) {
|
||||
return pipe<D>(
|
||||
initialData,
|
||||
merge<D>((arr: D[]) => arr.slice(skip, skip + limit), acc),
|
||||
);
|
||||
},
|
||||
|
||||
sortByField(fieldName: keyof D | Array<keyof D>, direction: 1 | -1 = 1, fallback: Array<keyof D> = []) {
|
||||
if (Array.isArray(fieldName)) {
|
||||
return this.sortByField(fieldName[0], direction, fieldName.slice(1));
|
||||
}
|
||||
|
||||
const sort = (fieldName: keyof D, direction: 1 | -1, fallback: Array<keyof D>) => {
|
||||
return (a: D, b: D): number => {
|
||||
const aValue = a[fieldName];
|
||||
const bValue = b[fieldName];
|
||||
|
||||
if (aValue === undefined && bValue === undefined && fallback.length > 0) {
|
||||
const [field, ...rest] = fallback;
|
||||
return sort(field, direction, rest)(a, b);
|
||||
}
|
||||
|
||||
if (aValue === undefined) {
|
||||
return 1;
|
||||
}
|
||||
|
||||
if (bValue === undefined) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
if (aValue instanceof Date && bValue instanceof Date) {
|
||||
return direction * (aValue.getTime() - bValue.getTime());
|
||||
}
|
||||
|
||||
if (typeof aValue === 'string' && typeof bValue === 'string') {
|
||||
return direction * aValue.localeCompare(bValue);
|
||||
}
|
||||
if (typeof aValue === 'number' && typeof bValue === 'number') {
|
||||
return direction * (aValue - bValue);
|
||||
}
|
||||
return 0;
|
||||
};
|
||||
};
|
||||
|
||||
return pipe<D>(
|
||||
initialData,
|
||||
merge((arr: D[]) => [...arr].sort(sort(fieldName, direction, fallback)), acc),
|
||||
);
|
||||
},
|
||||
|
||||
apply: (arg: D[]) => {
|
||||
return acc(initialData ?? arg);
|
||||
},
|
||||
pipe(p: IPipeReturn<D>) {
|
||||
return pipe<D>(initialData, merge(p.apply, acc));
|
||||
},
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user