@triplit/client
Version:
1,080 lines • 46.1 kB
JavaScript
import { NestedMap, hashQuery, TriplitError, prepareQuery, EntityStoreQueryEngine, getRolesFromSession, normalizeSessionVars, sessionRolesAreEquivalent, } from '@triplit/db';
import { WebSocketTransport } from './transport/websocket-transport.js';
import { NoActiveSessionError, RemoteSyncFailedError, SessionRolesMismatchError, TokenDecodingError, TokenExpiredError, } from './errors.js';
import SuperJSON from 'superjson';
import { createQueryWithExistsAddedToIncludes, createQueryWithRelationalOrderAddedToIncludes, queryResultsToChanges, } from '@triplit/db/ivm';
import { decodeToken, tokenIsExpired } from './token.js';
const QUERY_STATE_KEY = 'query-state';
function isEmpty(obj) {
for (const prop in obj) {
if (Object.hasOwn(obj, prop)) {
return false;
}
}
return true;
}
/**
* The SyncEngine is responsible for managing the connection to the server and syncing data
*/
export class SyncEngine {
transport;
client;
connectionChangeHandlers = new Set();
messageReceivedSubscribers = new Set();
messageSentSubscribers = new Set();
sessionErrorSubscribers = new Set();
entitySyncErrorSubscribers = new NestedMap();
entitySyncSuccessSubscribers = new NestedMap();
onFailureToSyncWritesSubscribers = new Set();
logger;
// Connection state - these are used to track the state of the connection and should reset on dis/reconnect
syncInProgress = false;
reconnectTimeoutDelay = 250;
reconnectTimeout;
serverReady = false;
// Session state - these are used to track the state of the session and should persist across reconnections, but reset on reset()
currentSession = undefined;
queries = new Map();
clientId = null;
/**
*
* @param options configuration options for the sync engine
* @param db the client database to be synced
*/
constructor(client, options) {
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(),
},
});
}
}
async sendChanges(changes) {
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, connect = true, refreshOptions) {
// 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) => {
const decoded = decodeToken(refreshToken);
if (!decoded)
return;
if (!decoded.exp && !interval)
return;
let delay = interval ?? decoded.exp * 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) {
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((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;
}
tokenRefreshTimer = 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() {
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.
*/
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;
}
}
async createRollbackBufferFromChanges(changes) {
const rollbackChanges = {};
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, id) {
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 = {
[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
*/
async updateTokenForSession(token) {
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) {
this.messageReceivedSubscribers.add(callback);
return () => {
this.messageReceivedSubscribers.delete(callback);
};
}
onSyncMessageSent(callback) {
this.messageSentSubscribers.add(callback);
return () => {
this.messageSentSubscribers.delete(callback);
};
}
onEntitySyncSuccess(collection, entityId, callback) {
this.entitySyncSuccessSubscribers.set(collection, entityId, callback);
return () => {
this.entitySyncSuccessSubscribers.delete(collection, entityId);
};
}
onEntitySyncError(collection, entityId, callback) {
this.entitySyncErrorSubscribers.set(collection, entityId, callback);
return () => {
this.entitySyncErrorSubscribers.delete(collection, entityId);
};
}
onFailureToSyncWrites(callback) {
this.onFailureToSyncWritesSubscribers.add(callback);
return () => {
this.onFailureToSyncWritesSubscribers.delete(callback);
};
}
onSessionError(callback) {
this.sessionErrorSubscribers.add(callback);
return () => {
this.sessionErrorSubscribers.delete(callback);
};
}
async getConnectionParams() {
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) {
if (this.client.awaitReady)
await this.client.awaitReady;
return !(await this.client.db.getMetadata([
QUERY_STATE_KEY,
hashQuery(query),
]));
}
async markQueryAsSeen(queryId) {
if (this.client.awaitReady)
await this.client.awaitReady;
return this.client.db.setMetadata([QUERY_STATE_KEY, queryId], true);
}
/**
* @hidden
*/
async subscribe(params, options = {}) {
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 = undefined;
if (onQueryFulfilled) {
query.syncState === 'FULFILLED' && onQueryFulfilled();
fulfillmentCallback = (state) => {
if (state === 'FULFILLED') {
onQueryFulfilled();
}
};
query.syncStateCallbacks.add(fulfillmentCallback);
}
let errorCallback = 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;
}
};
}
async connectQuery(queryId) {
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',
]));
let queryState = 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) {
const queryId = hashQuery(query);
const queryMetadata = this.queries.get(queryId);
return (queryMetadata && queryMetadata.syncState === 'FULFILLED') ?? false;
}
/**
* @hidden
*/
disconnectQuery(id) {
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
*/
lastParamsHash = undefined;
async connect() {
this.createConnection(this.currentSession);
}
/**
* Initiate a sync connection with the server
*/
createConnection(session) {
// 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));
}
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
onMessageHandler(session) {
return async (evt) => {
const message = 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(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();
}
}
};
}
onOpenHandler(session) {
return () => {
session.status = 'OPEN';
this.fireConnectionChangeHandlers(session);
this.resetReconnectTimeout();
this.logger.info('sync connection has opened');
};
}
onCloseHandler(session) {
return (evt) => {
// Clear any sync state
this.resetSyncConnectionState();
this.serverReady = false;
// If there is no reason, then default is to retry
if (evt.reason) {
let type;
let retry;
// 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);
}
}
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);
};
}
onErrorHandler(session) {
return (evt) => {
// 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();
};
}
lastKnownConnectionStatus = 'UNINITIALIZED';
fireConnectionChangeHandlers(session) {
// 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
*/
resetSyncConnectionState() {
this.syncInProgress = false;
for (const queryMetadata of this.queries.values()) {
queryMetadata.hasSent = false;
queryMetadata.syncState = 'NOT_STARTED';
}
}
async handleErrorMessage(message) {
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);
}
}
}
}
}
sendMessage(message) {
// 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, runImmediately = false) {
this.connectionChangeHandlers.add(callback);
if (runImmediately)
callback(this.connectionStatus);
return () => {
this.connectionChangeHandlers.delete(callback);
};
}
closeConnection(reason) {
this.transport.close(reason);
}
resetReconnectTimeout() {
clearTimeout(this.reconnectTimeout);
this.reconnectTimeoutDelay = 250;
}
/**
* @hidden
*/
async syncQuery(query) {
try {
let resolve, reject;
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.');
}
}
validateSessionWithWarning(session) {
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) {
const entityIds = {};
for (const [collection, collectionChanges] of Object.entries(changes)) {
const changedIds = [
...collectionChanges.sets.keys(),
...collectionChanges.deletes,
];
entityIds[collection] = changedIds;
}
return entityIds;
}
function throttle(callback, delay) {
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;
}
};
}
//# sourceMappingURL=sync-engine.js.map