stream-chat
Version:
JS SDK for the Stream Chat API
207 lines (184 loc) • 7.01 kB
text/typescript
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();
};
}