UNPKG

@triplit/client

Version:
1,346 lines (1,240 loc) 44.5 kB
import { DBChanges, NestedMap, hashQuery, CollectionQuery, TriplitError, hashObject, prepareQuery, EntityStoreQueryEngine, getRolesFromSession, normalizeSessionVars, sessionRolesAreEquivalent, } from '@triplit/db'; import { TriplitClient } from './client/triplit-client.js'; import { WebSocketTransport } from './transport/websocket-transport.js'; import { ClientSyncMessage, CloseReason, ServerCloseReasonType, ServerErrorMessage, ServerSyncMessage, QueryState, SyncTimestamp, } from './@triplit/types/sync.js'; import { NoActiveSessionError, RemoteSyncFailedError, SessionRolesMismatchError, TokenDecodingError, TokenExpiredError, } from './errors.js'; import { EntitySyncErrorCallback, EntitySyncSuccessCallback, ErrorCallback, OnMessageReceivedCallback, OnMessageSentCallback, OnSessionErrorCallback, QuerySyncState, SessionError, SyncOptions, SyncStateCallback, TokenRefreshOptions, } from './client/types'; import SuperJSON from 'superjson'; import { Logger } from '@triplit/logger'; import { ConnectionStatus, SyncTransport, TransportConnectParams, } from './types.js'; import { createQueryWithExistsAddedToIncludes, createQueryWithRelationalOrderAddedToIncludes, queryResultsToChanges, } from '@triplit/db/ivm'; import { decodeToken, tokenIsExpired } from './token.js'; const QUERY_STATE_KEY = 'query-state'; function isEmpty(obj: any) { for (const prop in obj) { if (Object.hasOwn(obj, prop)) { return false; } } return true; } type SyncSession = { serverUrl?: string; token: string; status: ConnectionStatus; // TODO: we have the opportunity to add more info here, might lead to cleaner code // hasConnected: boolean; // used to track if the session has connected at least once }; /** * The SyncEngine is responsible for managing the connection to the server and syncing data */ export class SyncEngine { private transport: SyncTransport; private client: TriplitClient<any>; private connectionChangeHandlers: Set<(status: ConnectionStatus) => void> = new Set(); private messageReceivedSubscribers: Set<OnMessageReceivedCallback> = new Set(); private messageSentSubscribers: Set<OnMessageSentCallback> = new Set(); private sessionErrorSubscribers: Set<OnSessionErrorCallback> = new Set(); private entitySyncErrorSubscribers: NestedMap< string, string, EntitySyncErrorCallback > = new NestedMap(); private entitySyncSuccessSubscribers: NestedMap< string, string, EntitySyncSuccessCallback > = new NestedMap(); private onFailureToSyncWritesSubscribers: Set< (e: unknown, writes: DBChanges) => void | Promise<void> > = new Set(); logger: Logger; // Connection state - these are used to track the state of the connection and should reset on dis/reconnect private syncInProgress: boolean = false; private reconnectTimeoutDelay = 250; private reconnectTimeout: any; private serverReady: boolean = false; // Session state - these are used to track the state of the session and should persist across reconnections, but reset on reset() currentSession: SyncSession | undefined = undefined; private queries: Map< string, { params: CollectionQuery<any, any>; syncState: QuerySyncState; syncStateCallbacks: Set<SyncStateCallback>; subCount: number; hasSent: boolean; abortController: AbortController; } > = new Map(); clientId: string | null = null; /** * * @param options configuration options for the sync engine * @param db the client database to be synced */ constructor(client: TriplitClient<any>, options: SyncOptions) { this.client = client; this.logger = options.logger; this.client.onConnectionOptionsChange((change) => { const shouldDisconnect = (this.connectionStatus === 'OPEN' || this.connectionStatus === 'CONNECTING') && // Server change or non refresh token change ('serverUrl' in change || ('token' in change && !change.tokenRefresh)); if (shouldDisconnect) { this.logger.warn( 'You are updating the connection options while the connection is open. To avoid unexpected behavior the connection will be closed and you should call `connect()` again after the update. To hide this warning, call `disconnect()` before updating the connection options.' ); this.disconnect(); } }); this.transport = options.transport ?? new WebSocketTransport(); this.onConnectionStatusChange((status) => { if (status === 'CLOSING' || status === 'CLOSED') { if (this.lastParamsHash !== undefined) { this.lastParamsHash = undefined; } } }); if (!!options.pingInterval) { const ping = setInterval( this.ping.bind(this), options.pingInterval * 1000 ); // In Node, unref() the ping so it doesn't block the process from exiting // TODO: improve typing of setInteval for better compatibility with browser and node if (typeof ping === 'object' && 'unref' in ping) ping.unref(); } } ping() { if (this.connectionStatus === 'OPEN' && this.serverReady) { this.sendMessage({ type: 'PING', payload: { clientTimestamp: Date.now(), }, }); } } private async sendChanges(changes: DBChanges) { this.sendMessage({ type: 'CHANGES', payload: { changes: SuperJSON.serialize(changes), }, }); } /** * Handles a new token and update the sync connection accordingly. * - If the token is the same as the current session, it will just connect if `connect` is true. * - If the token is different, it will reset the current session and start a new one with the new token. */ async assignSessionToken( token: string | undefined, connect = true, refreshOptions?: TokenRefreshOptions ) { // If the current params are the same as the new params, just connect if prompted if (token && this.currentSession) { // Assigning the same state as te existing session if ( this.currentSession.token === token && this.currentSession.serverUrl === this.client.serverUrl ) { if ( this.currentSession.status === 'OPEN' || this.currentSession.status === 'CONNECTING' ) return; await this.connect(); return; } } // if current session, tear it down if (this.currentSession) { this.resetTokenRefreshHandler(); this.disconnect(); // this.updateToken(undefined); this.resetQueryState(); this.currentSession = undefined; } // Set up a new session if (token) { this.currentSession = { serverUrl: this.client.serverUrl, token, status: 'UNINITIALIZED', // will be updated on connect }; } else { // If we arent starting a new session, fire in case we ended a previous session // Trying to make this smooth so there is only one synchronous fire after updating this.currentSession } this.fireConnectionChangeHandlers(this.currentSession); if (connect) { await this.connect(); } // 6. Set up a token refresh handler if provided // Setup token refresh handler if (!refreshOptions || !token) return; const { interval, refreshHandler } = refreshOptions; const setRefreshTimeoutForToken = (refreshToken: string) => { const decoded = decodeToken(refreshToken); if (!decoded) return; if (!decoded.exp && !interval) return; let delay = interval ?? (decoded.exp as number) * 1000 - Date.now() - 1000; if (delay < 1000) { this.logger.warn( `The minimum allowed refresh interval is 1000ms, the ${interval ? 'provided interval' : 'interval determined from the provided token'} was ${Math.round(delay)}ms.` ); delay = 1000; } this.tokenRefreshTimer = setTimeout(async () => { // May fail just because you're offline, handle by disconnecting and not nuking your session const maybeFreshToken = await refreshHandler(); if (!maybeFreshToken) { if ( this.connectionStatus === 'OPEN' || this.connectionStatus === 'CONNECTING' ) { this.logger.warn( 'The token refresh handler did not return a new token, disconnecting.' ); this.disconnect(); } // Keep trying (?), hopefully not a doom loop, but your refresh interval should be long enough to really overload things setRefreshTimeoutForToken(refreshToken); } else { await this.updateSessionToken(maybeFreshToken); setRefreshTimeoutForToken(maybeFreshToken); } }, delay); }; setRefreshTimeoutForToken(token); return () => { this.resetTokenRefreshHandler(); }; } /** * Attempts to update the token of the current session, which re-use the current connection. If the new token does not have the same roles as the current session, an error will be thrown. */ async updateSessionToken(token: string) { if (this.client.awaitReady) await this.client.awaitReady; if (!this.currentSession) { throw new NoActiveSessionError(); } const decodedToken = decodeToken(token); if (!decodedToken) throw new TokenDecodingError(decodedToken); if (tokenIsExpired(decodedToken)) throw new TokenExpiredError(); // probably could just get this from the client constructor options? // if we guarantee that the client is always using that schema const sessionRoles = getRolesFromSession( this.client.db.schema, normalizeSessionVars(decodedToken) ); if ( !sessionRolesAreEquivalent(this.client.db.session?.roles, sessionRoles) ) { throw new SessionRolesMismatchError(); } // @ts-expect-error private method this.client.updateToken(token, true); // TODO: handle offline gracefully const didSend = this.updateTokenForSession(token); if (!didSend) { // There is a chance the message to update the token wont send, for safety just try again once more const sentAfterDelay = await new Promise<boolean>((res, rej) => setTimeout(() => res(this.updateTokenForSession(token)), 1000) ); if (!sentAfterDelay) // TODO: end session? // If this throws, we should evaluate how to handle different states of our websocket transport and if/when we should queue messages throw new TriplitError( 'Failed to update the session token for the current session.' ); } this.currentSession.token = token; } private tokenRefreshTimer: ReturnType<typeof setTimeout> | null = null; resetTokenRefreshHandler() { if (this.tokenRefreshTimer) { clearTimeout(this.tokenRefreshTimer); this.tokenRefreshTimer = null; } } /** * Manually send any pending writes to the remote database. This may be a no-op if: * - there is already a push in progress * - the connection is not open * - the server is not ready * * This will switch the active and inactive buffers if we are able to push * * If the push is successful, it will return `success: true`. If the push fails, it will return `success: false` and a `failureReason`. */ async syncWrites(): Promise<{ didSync: boolean; syncFailureReason?: string; }> { if (this.syncInProgress) { return { didSync: false, syncFailureReason: 'Sync in progress', }; } if (this.connectionStatus !== 'OPEN') { return { didSync: false, syncFailureReason: 'Connection not open', }; } if (!this.serverReady) { return { didSync: false, syncFailureReason: 'Server not ready', }; } if (this.client.awaitReady) await this.client.awaitReady; // We are good to sync, check if we should switch buffers and attempt sync const shouldSwitch = await this.client.db.entityStore.doubleBuffer .getLockedBuffer() .isEmpty(this.client.db.kv); if (shouldSwitch) { this.client.db.entityStore.doubleBuffer.lockAndSwitchBuffers(); } await this.trySyncLockedBuffer(); return { didSync: true, }; } /** * FOR INTERNAL USE ONLY, in most cases (even internally) you should use the safer `syncWrites` method * * This method will attempt to send the changes in the locked buffer to the server and mutates the `syncInProgress` state. */ private async trySyncLockedBuffer() { // Block others from attempting sync this.syncInProgress = true; try { const changes = await this.client.db.entityStore.doubleBuffer .getLockedBuffer() .getChanges(this.client.db.kv); if (!isEmpty(changes)) { // Just in case it was toggled off during any async processing this.syncInProgress = true; return this.sendChanges(changes); } else { // No changes, so weve synced this.syncInProgress = false; } } catch (e) { // Something failed so not in progress this.syncInProgress = false; throw e; } } private async createRollbackBufferFromChanges( changes: DBChanges ): Promise<DBChanges> { const rollbackChanges: DBChanges = {}; for (const [collection, { sets, deletes }] of Object.entries(changes)) { rollbackChanges[collection] = { sets: new Map(), deletes: new Set() }; const dataStore = this.client.db.entityStore.dataStore; const kv = this.client.db.kv; // Handle deletes first, and don't revert a delete // if there was nothing in the cache to begin with for (const id of deletes) { const entity = await dataStore.getEntity(kv, collection, id); if (entity) rollbackChanges[collection].sets.set(id, entity); } // Handle sets for (const id of sets.keys()) { if (rollbackChanges[collection].sets.has(id)) continue; const entity = await dataStore.getEntity(kv, collection, id); if (entity) { rollbackChanges[collection].sets.set(id, entity); } else { rollbackChanges[collection].deletes.add(id); } } } return rollbackChanges; } async clearPendingChangesForEntity(collection: string, id: string) { if (this.client.awaitReady) await this.client.awaitReady; const tx = this.client.db.kv.transact(); const outboxChange = await this.client.db.entityStore.doubleBuffer .getUnlockedBuffer() .getChangesForEntity(tx, collection, id); const buffer: DBChanges = { [collection]: { sets: new Map(), deletes: new Set(), }, }; if (outboxChange) { if (outboxChange.delete) { buffer[collection].deletes.add(id); } if (outboxChange.update) { buffer[collection].sets.set(id, outboxChange.update); } } const rollbackChanges = await this.createRollbackBufferFromChanges(buffer); await this.client.db.entityStore.doubleBuffer .getUnlockedBuffer() .clearChangesForEntity(tx, collection, id); await tx.commit(); await this.client.db.ivm.bufferChanges(rollbackChanges); await this.client.db.updateQueryViews(); this.client.db.broadcastToQuerySubscribers(); // because we've surgically removed the changes for this entity // there might be other changes that we can sync // TODO: is this desired behavior every time? return this.syncWrites(); } async clearPendingChangesAll() { if (this.client.awaitReady) await this.client.awaitReady; const tx = this.client.db.kv.transact(); const changes = await this.client.db.entityStore.doubleBuffer .getUnlockedBuffer() .getChanges(this.client.db.kv); const rollbackChanges = await this.createRollbackBufferFromChanges(changes); await this.client.db.entityStore.doubleBuffer.getUnlockedBuffer().clear(tx); await tx.commit(); await this.client.db.ivm.bufferChanges(rollbackChanges); await this.client.db.updateQueryViews(); this.client.db.broadcastToQuerySubscribers(); } /** * @hidden */ private async updateTokenForSession(token: string) { try { await fetch(`${this.client.serverUrl}/update-token`, { method: 'POST', body: JSON.stringify({ clientId: this.clientId, }), headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}`, }, }); return true; } catch (e) { console.error(e); // @ts-expect-error this.logger.error('Failed to update token', e); return false; } } onSyncMessageReceived(callback: OnMessageReceivedCallback) { this.messageReceivedSubscribers.add(callback); return () => { this.messageReceivedSubscribers.delete(callback); }; } onSyncMessageSent(callback: OnMessageSentCallback) { this.messageSentSubscribers.add(callback); return () => { this.messageSentSubscribers.delete(callback); }; } onEntitySyncSuccess( collection: string, entityId: string, callback: () => void ) { this.entitySyncSuccessSubscribers.set(collection, entityId, callback); return () => { this.entitySyncSuccessSubscribers.delete(collection, entityId); }; } onEntitySyncError( collection: string, entityId: string, callback: EntitySyncErrorCallback ) { this.entitySyncErrorSubscribers.set(collection, entityId, callback); return () => { this.entitySyncErrorSubscribers.delete(collection, entityId); }; } onFailureToSyncWrites(callback: (e: unknown) => void) { this.onFailureToSyncWritesSubscribers.add(callback); return () => { this.onFailureToSyncWritesSubscribers.delete(callback); }; } onSessionError(callback: OnSessionErrorCallback) { this.sessionErrorSubscribers.add(callback); return () => { this.sessionErrorSubscribers.delete(callback); }; } async getConnectionParams(): Promise<Partial<TransportConnectParams>> { if (this.client.awaitReady) await this.client.awaitReady; return { syncSchema: this.client.syncSchema, token: this.client.token, server: this.client.serverUrl, }; } // TODO: determine future of query states async isFirstTimeFetchingQuery(query: CollectionQuery<any, any>) { if (this.client.awaitReady) await this.client.awaitReady; return !(await this.client.db.getMetadata([ QUERY_STATE_KEY, hashQuery(query), ])); } private async markQueryAsSeen(queryId: string) { if (this.client.awaitReady) await this.client.awaitReady; return this.client.db.setMetadata([QUERY_STATE_KEY, queryId], true); } /** * @hidden */ async subscribe( params: CollectionQuery<any, any>, options: { onQueryFulfilled?: () => void; onQueryError?: ErrorCallback; onQuerySyncStateChange?: SyncStateCallback; } = {} ) { const { onQueryFulfilled, onQueryError, onQuerySyncStateChange } = options; const id = hashQuery(params); const queryHasMounted = this.queries.has(id); if (!queryHasMounted) { this.queries.set(id, { params, syncState: 'NOT_STARTED', syncStateCallbacks: new Set(), subCount: 0, hasSent: false, abortController: new AbortController(), }); } // Safely using query! here because we just set it const query = this.queries.get(id)!; query.subCount++; if (onQuerySyncStateChange) { query.syncStateCallbacks.add(onQuerySyncStateChange); } let fulfillmentCallback: SyncStateCallback | undefined = undefined; if (onQueryFulfilled) { query.syncState === 'FULFILLED' && onQueryFulfilled(); fulfillmentCallback = (state) => { if (state === 'FULFILLED') { onQueryFulfilled(); } }; query.syncStateCallbacks.add(fulfillmentCallback); } let errorCallback: SyncStateCallback | undefined = undefined; if (onQueryError) { errorCallback = (state, error) => { if (state === 'ERROR') { onQueryError(error); } }; query.syncStateCallbacks.add(errorCallback); } if (!queryHasMounted) { await this.connectQuery(id); } return () => { const query = this.queries.get(id); // If we cannot find the query, we may have already disconnected or reset our state // just in case send a disconnect signal to the server if (!query) { this.disconnectQuery(id); return; } // Clear data related to subscription query.subCount--; if (fulfillmentCallback) { query.syncStateCallbacks.delete(fulfillmentCallback); } if (errorCallback) { query.syncStateCallbacks.delete(errorCallback); } if (onQuerySyncStateChange) { query.syncStateCallbacks.delete(onQuerySyncStateChange); } // If there are no more subscriptions, disconnect the query if (query.subCount === 0) { this.disconnectQuery(id); return; } }; } private async connectQuery(queryId: string) { if (this.client.awaitReady) await this.client.awaitReady; if (!this.queries.has(queryId)) return; const queryMetadata = this.queries.get(queryId); /** * Do not send CONNECT_QUERY message if: * - query no longer exists * - query has already been sent in this session * - query has been aborted * - server is not ready */ if ( !queryMetadata || queryMetadata.hasSent || queryMetadata.abortController.signal.aborted || !this.serverReady ) { return; } const latestServerTimestamp = (await this.client.db.getMetadata([ 'latest_server_timestamp', ])) as SyncTimestamp | undefined; let queryState: QueryState | undefined = undefined; if (latestServerTimestamp) { const queryWithRelationalInclusions = createQueryWithRelationalOrderAddedToIncludes( createQueryWithExistsAddedToIncludes( prepareQuery( queryMetadata.params, this.client.db.schema?.['collections'], {}, undefined, { applyPermission: undefined, } ) ) ); // We should only consider your cache data for checkpointed fetch // We dont have a great API for this right now, so using the DB's query engine directly const queryEngine = new EntityStoreQueryEngine( this.client.db.kv, this.client.db.entityStore.dataStore ); const syncedResults = await queryEngine.fetch( queryWithRelationalInclusions ); const changesFromResults = queryResultsToChanges( syncedResults, queryWithRelationalInclusions ); const entityIds = changesToEntityIds(changesFromResults); queryState = { timestamp: latestServerTimestamp, // we should be able to retrieve these from the denormalized changes // that are stored in IVM, assuming that the subscription is initialized // In the case of background subscription, there won't be // any record of the query in IVM, so we can just fall back to fetchChanges // BUT if we go down that way then there's a contract that what // comes out of IVM and watch we get from fetchChanges is the same entityIds, }; } const didSend = this.sendMessage({ type: 'CONNECT_QUERY', payload: { id: queryId, params: queryMetadata.params, state: queryState, }, }); if (didSend) { queryMetadata.syncState = 'IN_FLIGHT'; for (const callback of queryMetadata.syncStateCallbacks) { callback('IN_FLIGHT', undefined); } queryMetadata.hasSent = true; } return didSend; } hasServerRespondedForQuery(query: CollectionQuery<any, any>) { const queryId = hashQuery(query); const queryMetadata = this.queries.get(queryId); return (queryMetadata && queryMetadata.syncState === 'FULFILLED') ?? false; } /** * @hidden */ disconnectQuery(id: string) { if (!this.queries.has(id)) return; const queryMetadata = this.queries.get(id)!; if (queryMetadata.hasSent) { this.sendMessage({ type: 'DISCONNECT_QUERY', payload: { id } }); } else { queryMetadata.abortController.abort(); } this.queries.delete(id); } /** * A hash of the last set of connected params, should not reconnect if the same params are used twice and the connection is already open */ private lastParamsHash: number | undefined = undefined; async connect() { this.createConnection(this.currentSession); } /** * Initiate a sync connection with the server */ createConnection(session: SyncSession | undefined) { // Validate that there is enough information to connect if (!this.validateSessionWithWarning(session)) return; // If we are creating a connection for a session that is not the current session, we should not proceed if (this.currentSession !== session) return; // If we are already connected, we should not proceed if (session.status === 'OPEN') return; if (session.status === 'CONNECTING') { console.warn('Already connecting, ignoring connect call'); return; } session.status = 'CONNECTING'; this.fireConnectionChangeHandlers(session); // if (isOpeningConnection) { // console.log('OPENING CONNECTION'); // // this.lastParamsHash = undefined; // reset lastParamsHash // } // // TODO: we are sort of double checking this // const paramsHash = hashObject({ // token: this.currentSession.token, // server: this.currentSession.serverUrl, // }); // console.log(paramsHash, this.lastParamsHash); // // We can get stuck CONNECTING here in reconnect loop // // Dont reconnect with the same parameters // // if (this.lastParamsHash === paramsHash) return; // Setup connection this.transport.connect({ token: session.token, server: session.serverUrl, syncSchema: false, schema: undefined, }); // Setup listeners // There is still probably too much "global" state that we should continue to refactor // To prevent confusion, we are binding the handlers to the current session so they only update that session this.transport.onMessage(this.onMessageHandler(session).bind(this)); this.transport.onOpen(this.onOpenHandler(session).bind(this)); this.transport.onClose(this.onCloseHandler(session).bind(this)); this.transport.onError(this.onErrorHandler(session).bind(this)); } private async initializeSync() { const syncStatus = await this.syncWrites(); if (!syncStatus.didSync) { this.logger.warn( `Failed to send changes on initialization: ${syncStatus.syncFailureReason}` ); } // Reconnect any queries for (const [id] of this.queries) { this.connectQuery(id); } } // TODO: add an onError handler to gracefully handle errors in message handlers private onMessageHandler(session: SyncSession) { return async (evt: any) => { const message: ServerSyncMessage = JSON.parse(evt.data); this.logger.debug('received', message); for (const handler of this.messageReceivedSubscribers) { handler(message); } if (message.type === 'ERROR') { await this.handleErrorMessage(message); } if (message.type === 'ENTITY_DATA') { const { changes: stringifiedChanges, timestamp, forQueries: queryIds, } = message.payload; const changes = SuperJSON.deserialize<DBChanges>(stringifiedChanges); // first apply changes // the db will push these onto IVMs buffer if (this.client.awaitReady) await this.client.awaitReady; await this.client.db.applyChangesWithTimestamp(changes, timestamp, { skipRules: true, }); // TODO do in same transaction await this.client.db.setMetadata( ['latest_server_timestamp'], timestamp ); // then update the query fulfillment state so that // the client can signal in the results handler // that the next time IVM fires, it's because // of the server's response for (const qId of queryIds) { const query = this.queries.get(qId); if (!query) continue; if (query.syncState !== 'FULFILLED') { await this.markQueryAsSeen(qId); query.syncState = 'FULFILLED'; } // this.queryFulfillmentCallbacks.delete(qId); } // update IVM await this.client.db.updateQueryViews(); this.client.db.broadcastToQuerySubscribers(); // finally, run the query fulfillment callbacks for (const qId of queryIds) { const query = this.queries.get(qId); if (!query) continue; for (const callback of query.syncStateCallbacks) { callback('FULFILLED', message.payload); } } } if (message.type === 'CHANGES_ACK') { if (this.client.awaitReady) await this.client.awaitReady; const ackedChanges = await this.client.db.entityStore.doubleBuffer .getLockedBuffer() .getChanges(this.client.db.kv); // write the acked changes to the outbox const tx = this.client.db.kv.transact(); // go through to the entity store because // that will skip buffering IVM await this.client.db.entityStore.applyChangesWithTimestamp( tx, ackedChanges, message.payload.timestamp, { checkWritePermission: undefined, entityChangeValidator: undefined, } ); await this.client.db.entityStore.doubleBuffer .getLockedBuffer() .clear(tx); await tx.commit(); for (const [collection, entityCallbackMap] of this .entitySyncSuccessSubscribers) { const collectionChanges = ackedChanges[collection]; if (!collectionChanges) continue; for (const [id, callback] of entityCallbackMap) { if ( collectionChanges.sets.has(id) || collectionChanges.deletes.has(id) ) { // Not awaiting as these callbacks are not designed to interrupt/disrupt outbox // processing callback(); } } } this.syncInProgress = false; // empty the outbox // this.checkUnlockedBufferAndSendAnyChanges(); await this.syncWrites(); } if (message.type === 'CLOSE') { const { payload } = message; this.logger.info( `Closing connection${payload?.message ? `: ${payload.message}` : '.'}` ); const { type, retry } = payload; // Close payload must remain under 125 bytes this.closeConnection({ type, retry }); } if (message.type === 'SCHEMA_REQUEST') { const schema = await this.client.getSchema(); this.sendMessage({ type: 'SCHEMA_RESPONSE', payload: { schema }, }); } if (message.type === 'READY') { const { payload } = message; const { clientId } = payload; this.clientId = clientId; if (!this.serverReady) { this.serverReady = true; await this.initializeSync(); } } }; } private onOpenHandler(session: SyncSession) { return () => { session.status = 'OPEN'; this.fireConnectionChangeHandlers(session); this.resetReconnectTimeout(); this.logger.info('sync connection has opened'); }; } private onCloseHandler(session: SyncSession) { return (evt: any) => { // Clear any sync state this.resetSyncConnectionState(); this.serverReady = false; // If there is no reason, then default is to retry if (evt.reason) { let type: ServerCloseReasonType; let retry: boolean; // We populate the reason field with some information about the close // Some WS implementations include a reason field that isn't a JSON string on connection failures, etc try { const { type: t, retry: r } = JSON.parse(evt.reason); type = t; retry = r; } catch (e) { type = 'UNKNOWN'; retry = true; } if (type === 'UNAUTHORIZED') { this.logger.error( 'The server has closed the connection because the client is unauthorized. Please provide a valid token.' ); } if (type === 'SCHEMA_MISMATCH') { this.logger.error( 'The server has closed the connection because the client schema does not match the server schema. Please update your client schema.' ); } if (type === 'TOKEN_EXPIRED') { this.logger.error( 'The server has closed the connection because the token has expired. Fetch a new token from your authentication provider and call `TriplitClient.endSession()` and `TriplitClient.startSession(token)` to restart the session.' ); } if (type === 'ROLES_MISMATCH') { this.logger.error( 'The server has closed the connection because the client attempted to update the session with a token that has different roles than the existing token. Call `TriplitClient.endSession()` and `TriplitClient.startSession(token)` to restart the session with the new token.' ); } if ( [ 'ROLES_MISMATCH', 'TOKEN_EXPIRED', 'SCHEMA_MISMATCH', 'UNAUTHORIZED', ].includes(type) ) { for (const handler of this.sessionErrorSubscribers) { handler(type as SessionError); } } if (!retry) { // early return to prevent reconnect this.logger.warn( 'The connection has closed. Based on the signal, the connection will not automatically retry. If you would like to reconnect, please call `connect()`.' ); session.status = 'CLOSED'; this.fireConnectionChangeHandlers(session); return; } } // TODO: what is the right way to smooth this out? session.status = 'CLOSED'; this.fireConnectionChangeHandlers(session); // Attempt to reconnect with backoff const connectionHandler = this.connect.bind(this); this.reconnectTimeout = setTimeout( connectionHandler, this.reconnectTimeoutDelay ); this.reconnectTimeoutDelay = Math.min( 300000, // 5 minutes max this.reconnectTimeoutDelay * 2 ); }; } private onErrorHandler(session: SyncSession) { return (evt: any) => { // WS errors are intentionally vague, so just log a message this.logger.error( 'An error occurred during the connection to the server. Retrying connection...' ); // on error, close the connection and attempt to reconnect this.closeConnection(); }; } private lastKnownConnectionStatus: ConnectionStatus = 'UNINITIALIZED'; private fireConnectionChangeHandlers(session: SyncSession | undefined) { // ONLY fire connection change handlers if the session is the current session // This prevents firing handlers for old sessions that are no longer active const isCurrentSession = session === this.currentSession; if (!isCurrentSession) return; // If the status has not changed, do not fire handlers const statusChanged = this.lastKnownConnectionStatus !== this.connectionStatus; if (!statusChanged) return; this.lastKnownConnectionStatus = this.connectionStatus; for (const handler of this.connectionChangeHandlers) { handler(this.connectionStatus); } } /** * The current connection status of the sync engine */ get connectionStatus() { if (!this.currentSession) return 'UNINITIALIZED'; return this.currentSession.status; } /** * Disconnect from the server */ disconnect() { if (this.currentSession) { this.currentSession.status = 'CLOSING'; this.fireConnectionChangeHandlers(this.currentSession); } this.closeConnection({ type: 'MANUAL_DISCONNECT', retry: false }); } /** * Resets the server acks for remote queries. * On the next connection, queries will be re-sent to server as if there is no previous seen data. * If the connection is currently open, it will be closed and you will need to call `connect()` again. */ // TODO: we have a different queryState concept so this is confusing resetQueryState() { if (this.connectionStatus === 'OPEN') { this.logger.warn( 'You are resetting the sync engine while the connection is open. To avoid unexpected behavior the connection will be closed and you should call `connect()` again after resetting. To hide this warning, call `disconnect()` before resetting.' ); this.disconnect(); } this.resetSyncConnectionState(); } /** * Resets all state related to a sync connection (so if we lose connection, this should reset) * * Marks all queries as unsent and resets the syncInProgress indicator, so on next connection we will re-send data to the server */ private resetSyncConnectionState() { this.syncInProgress = false; for (const queryMetadata of this.queries.values()) { queryMetadata!.hasSent = false; queryMetadata!.syncState = 'NOT_STARTED'; } } private async handleErrorMessage(message: ServerErrorMessage) { const { error, metadata, messageType } = message.payload; this.logger.error(error.name, metadata); switch (error.name) { case 'MalformedMessagePayloadError': case 'UnrecognizedMessageTypeError': this.logger.warn( 'You sent a malformed message to the server. This might occur if your client is not up to date with the server. Please ensure your client is updated.' ); break; // On a remote read error, default to disconnecting the query // You will still send triples, but you wont receive updates case 'QuerySyncError': const queryKey = metadata?.queryKey; if (queryKey) { const query = this.queries.get(queryKey); if (query) { const parsedError = TriplitError.fromJson(error); query.syncState = 'ERROR'; for (const callback of query.syncStateCallbacks) { // TODO: include metadata (inner error) await callback('ERROR', parsedError); } } this.disconnectQuery(queryKey); } } if (messageType === 'CHANGES') { if (this.client.awaitReady) await this.client.awaitReady; const kvTx = this.client.db.kv.transact(); const outbox = this.client.db.entityStore.doubleBuffer; // can we have the server send this back instead of reading the potentially // unstable buffer? const failedChanges = await outbox.getLockedBuffer().getChanges(kvTx); // rebase the unlocked buffer on the failed locked buffer await outbox .getLockedBuffer() .write(kvTx, await outbox.getUnlockedBuffer().getChanges(kvTx)); await outbox.getUnlockedBuffer().clear(kvTx); await kvTx.commit(); // now we can switch the buffers so that the // client can write to the unlocked buffer outbox.lockAndSwitchBuffers(); this.syncInProgress = false; for (const handler of this.onFailureToSyncWritesSubscribers) { await handler(error, failedChanges); } for (const collection in failedChanges) { // TODO: layer in deletes for (const [id, change] of failedChanges[collection].sets) { const errorCallback = this.entitySyncErrorSubscribers.get( collection, id ); if (errorCallback) { // should we be providing the change or the full entity? // TODO: ts fixups for error passed in // should this just be the root error instead of in this // failures array? // @ts-expect-error await errorCallback(metadata?.failures[0]?.error, change); } } } } } private sendMessage(message: ClientSyncMessage) { // TODO: it might be safe to prevent sending some messages if the server hasnt indicated its ready yet // Allowed messages might include token exchange info and schema exchange info const didSend = this.transport.sendMessage(message); if (didSend) { this.logger.debug('sent', message); for (const handler of this.messageSentSubscribers) { handler(message); } } return didSend; } /** * Sets up a listener for connection status changes * @param callback A callback that will be called when the connection status changes * @param runImmediately Run the callback immediately with the current connection status * @returns A function that removes the callback from the connection status change listeners */ onConnectionStatusChange( callback: (status: ConnectionStatus) => void, runImmediately: boolean = false ) { this.connectionChangeHandlers.add(callback); if (runImmediately) callback(this.connectionStatus); return () => { this.connectionChangeHandlers.delete(callback); }; } private closeConnection(reason?: CloseReason) { this.transport.close(reason); } private resetReconnectTimeout() { clearTimeout(this.reconnectTimeout); this.reconnectTimeoutDelay = 250; } /** * @hidden */ async syncQuery(query: CollectionQuery<any, any>) { try { let resolve: (value: unknown) => void, reject: (reason?: any) => void; const promise = new Promise((res, rej) => { resolve = res; reject = rej; }); const unsubPromise = this.subscribe(query, { onQueryFulfilled: async () => { const unsub = await unsubPromise; resolve(void 0); unsub(); }, }); return promise; } catch (e) { if (e instanceof TriplitError) throw e; if (e instanceof Error) throw new RemoteSyncFailedError(query, e.message); throw new RemoteSyncFailedError(query, 'An unknown error occurred.'); } } private validateSessionWithWarning( session: SyncSession | undefined ): session is Required<SyncSession> { if (!session) { this.logger.warn( 'You are attempting to connect to the server but no session is defined. Please ensure you are providing a token and serverUrl in the TriplitClient constructor or run startSession(token) to setup a session.' ); return false; } const missingParams = []; if (!session.token) missingParams.push('token'); if (!session.serverUrl) missingParams.push('serverUrl'); if (missingParams.length) { this.logger.warn( `You are attempting to connect but the connection cannot be opened because the required parameters are missing: [${missingParams.join( ', ' )}].` ); return false; } return true; } } function changesToEntityIds(changes: DBChanges): Record<string, string[]> { const entityIds: Record<string, string[]> = {}; for (const [collection, collectionChanges] of Object.entries(changes)) { const changedIds = [ ...collectionChanges.sets.keys(), ...collectionChanges.deletes, ]; entityIds[collection] = changedIds; } return entityIds; } function throttle(callback: () => void, delay: number) { let wait = false; let refire = false; function refireOrReset() { if (refire) { callback(); refire = false; setTimeout(refireOrReset, delay); } else { wait = false; } } return function () { if (!wait) { callback(); wait = true; setTimeout(refireOrReset, delay); } else { refire = true; } }; }