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
Code scanning - action / CodeQL-Build (push) Failing after 2s
Auto-close duplicate issues / auto-close-duplicates (push) Failing after 1s
404 lines
11 KiB
TypeScript
404 lines
11 KiB
TypeScript
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();
|
|
});
|
|
}
|
|
}
|