UNPKG

stream-chat

Version:

JS SDK for the Stream Chat API

207 lines (184 loc) 7.01 kB
import type { ExecuteBatchDBQueriesType } from './types'; import type { StreamChat } from '../client'; import type { AbstractOfflineDB } from './offline_support_api'; import type { AxiosError } from 'axios'; import { isAxiosError } from 'axios'; import type { APIErrorResponse } from '../types'; /** * Manages synchronization between the local offline database and the Stream backend. * * Responsible for detecting connection changes, syncing channel data, and executing * pending tasks queued during offline periods. This class ensures the database remains * consistent with the server once connectivity is restored. */ export class OfflineDBSyncManager { public syncStatus = false; public connectionChangedListener: { unsubscribe: () => void } | null = null; private syncStatusListeners: Array<(status: boolean) => void> = []; private scheduledSyncStatusCallbacks: Map<string | symbol, () => Promise<void>> = new Map(); private client: StreamChat; private offlineDb: AbstractOfflineDB; constructor({ client, offlineDb, }: { client: StreamChat; offlineDb: AbstractOfflineDB; }) { this.client = client; this.offlineDb = offlineDb; } /** * Initializes the sync manager. Should only be called once per session. * * Cleans up old listeners if re-initialized to avoid memory leaks. * Starts syncing immediately if already connected, otherwise waits for reconnection. */ public init = async () => { try { // If the websocket connection is already active, then call // the sync api straight away and also execute pending api calls. // Otherwise wait for the `connection.changed` event. if (this.client.user?.id && this.client.wsConnection?.isHealthy) { await this.syncAndExecutePendingTasks(); await this.invokeSyncStatusListeners(true); } // If a listener has already been registered, unsubscribe from it so // that it can be reinstated. This can happen if we reconnect with a // different user or the component invoking the init() function gets // unmounted and then remounted again. This part of the code makes // sure the stale listener doesn't produce a memory leak. if (this.connectionChangedListener) { this.connectionChangedListener.unsubscribe(); } this.connectionChangedListener = this.client.on( 'connection.changed', async (event) => { if (event.online) { await this.syncAndExecutePendingTasks(); await this.invokeSyncStatusListeners(true); } else { await this.invokeSyncStatusListeners(false); } }, ); } catch (error) { console.log('Error in DBSyncManager.init: ', error); } }; /** * Registers a listener that is called whenever the sync status changes. * * @param listener - A callback invoked with the new sync status (`true` or `false`). * @returns An object with an `unsubscribe` function to remove the listener. */ public onSyncStatusChange = (listener: (status: boolean) => void) => { this.syncStatusListeners.push(listener); return { unsubscribe: () => { this.syncStatusListeners = this.syncStatusListeners.filter( (el) => el !== listener, ); }, }; }; /** * Schedules a one-time callback to be invoked after the next successful sync. * * @param tag - A unique key to identify and manage the callback. * @param callback - An async function to run after sync. */ public scheduleSyncStatusChangeCallback = ( tag: string | symbol, callback: () => Promise<void>, ) => { this.scheduledSyncStatusCallbacks.set(tag, callback); }; /** * Invokes all registered sync status listeners and executes any scheduled sync callbacks. * * @param status - The new sync status (`true` or `false`). */ private invokeSyncStatusListeners = async (status: boolean) => { this.syncStatus = status; this.syncStatusListeners.forEach((l) => l(status)); if (status) { const promises = Array.from(this.scheduledSyncStatusCallbacks.values()).map((cb) => cb(), ); await Promise.all(promises); this.scheduledSyncStatusCallbacks.clear(); } }; /** * Performs synchronization with the Stream backend. * * This includes downloading events since the last sync, updating the local DB, * and handling sync failures (e.g., if syncing beyond the allowed retention window). */ private sync = async () => { if (!this.client?.user) { return; } try { const cids = await this.offlineDb.getAllChannelCids(); // If there are no channels, then there is no need to sync. if (cids.length === 0) { return; } // TODO: We should not need our own user ID in the API, it can be inferred const lastSyncedAt = await this.offlineDb.getLastSyncedAt({ userId: this.client.user.id, }); if (lastSyncedAt) { const lastSyncedAtDate = new Date(lastSyncedAt); const nowDate = new Date(); // Calculate the difference in days const diff = Math.floor( (nowDate.getTime() - lastSyncedAtDate.getTime()) / (1000 * 60 * 60 * 24), ); if (diff > 30) { // stream backend will send an error if we try to sync after 30 days. // In that case reset the entire DB and start fresh. await this.offlineDb.resetDB(); } else { const result = await this.client.sync(cids, lastSyncedAtDate.toISOString()); const queryPromises = result.events.map((event) => this.offlineDb.handleEvent({ event, execute: false }), ); const queriesArray = await Promise.all(queryPromises); const queries = queriesArray.flat() as ExecuteBatchDBQueriesType; if (queries.length) { await this.offlineDb.executeSqlBatch(queries); } } } await this.offlineDb.upsertUserSyncStatus({ userId: this.client.user.id, lastSyncedAt: new Date().toString(), }); } catch (e) { console.log('An error has occurred while syncing the DB.', e); if (isAxiosError(e) && e.code === 'ECONNABORTED') { // If the sync was aborted due to timeout, we can simply return return; } const error = e as AxiosError<APIErrorResponse>; if (error.response?.data?.code === 23) { return; } // Error will be raised by the sync API if there are too many events. // In that case reset the entire DB and start fresh. // We avoid resetting the DB if the error is due to timeout. await this.offlineDb.resetDB(); } }; /** * Executes any tasks that were queued while offline and then performs a sync. */ private syncAndExecutePendingTasks = async () => { await this.offlineDb.executePendingTasks(); await this.sync(); }; }