UNPKG

@instantdb/core

Version:

Instant's core local abstraction

1,155 lines (1,154 loc) 73.9 kB
var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) { function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); } return new (P || (P = Promise))(function (resolve, reject) { function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } } function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } } function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); } step((generator = generator.apply(thisArg, _arguments || [])).next()); }); }; // @ts-check import weakHash from "./utils/weakHash.js"; import instaql from './instaql.js'; import * as instaml from './instaml.js'; import * as s from './store.js'; import uuid from "./utils/uuid.js"; import IndexedDBStorage from './IndexedDBStorage.js'; import WindowNetworkListener from './WindowNetworkListener.js'; import * as authAPI from "./authAPI.js"; import * as StorageApi from "./StorageAPI.js"; import * as flags from "./utils/flags.js"; import { buildPresenceSlice, hasPresenceResponseChanged } from "./presence.js"; import { Deferred } from './utils/Deferred.js'; import { PersistedObject } from './utils/PersistedObject.js'; import { extractTriples } from './model/instaqlResult.js'; import { areObjectsDeepEqual, assocInMutative, dissocInMutative, insertInMutative, } from './utils/object.js'; import { createLinkIndex } from "./utils/linkIndex.js"; import version from './version.js'; import { create } from 'mutative'; import createLogger from "./utils/log.js"; /** @typedef {import('./utils/log.ts').Logger} Logger */ const STATUS = { CONNECTING: 'connecting', OPENED: 'opened', AUTHENTICATED: 'authenticated', CLOSED: 'closed', ERRORED: 'errored', }; const QUERY_ONCE_TIMEOUT = 30000; const PENDING_TX_CLEANUP_TIMEOUT = 30000; const WS_CONNECTING_STATUS = 0; const WS_OPEN_STATUS = 1; const defaultConfig = { apiURI: 'https://api.instantdb.com', websocketURI: 'wss://api.instantdb.com/runtime/session', }; // Param that the backend adds if this is an oauth redirect const OAUTH_REDIRECT_PARAM = '_instant_oauth_redirect'; const currentUserKey = `currentUser`; let _wsId = 0; function createWebSocket(uri) { const ws = new WebSocket(uri); // @ts-ignore ws._id = _wsId++; return ws; } function isClient() { const hasWindow = typeof window !== 'undefined'; // this checks if we are running in a chrome extension // @ts-expect-error const isChrome = typeof chrome !== 'undefined'; return hasWindow || isChrome; } const ignoreLogging = { 'set-presence': true, 'set-presence-ok': true, 'refresh-presence': true, 'patch-presence': true, }; function querySubsFromJSON(str) { var _a; const parsed = JSON.parse(str); for (const key in parsed) { const v = parsed[key]; if ((_a = v === null || v === void 0 ? void 0 : v.result) === null || _a === void 0 ? void 0 : _a.store) { v.result.store = s.fromJSON(v.result.store); } } return parsed; } function querySubsToJSON(querySubs) { var _a; const jsonSubs = {}; for (const key in querySubs) { const sub = querySubs[key]; const jsonSub = Object.assign({}, sub); if ((_a = sub.result) === null || _a === void 0 ? void 0 : _a.store) { jsonSub.result = Object.assign(Object.assign({}, sub.result), { store: s.toJSON(sub.result.store) }); } jsonSubs[key] = jsonSub; } return JSON.stringify(jsonSubs); } function sortedMutationEntries(entries) { return [...entries].sort((a, b) => { const [ka, muta] = a; const [kb, mutb] = b; const a_order = muta.order || 0; const b_order = mutb.order || 0; if (a_order == b_order) { return ka < kb ? -1 : ka > kb ? 1 : 0; } return a_order - b_order; }); } /** * @template {import('./presence.ts').RoomSchemaShape} [RoomSchema = {}] */ export default class Reactor { constructor(config, Storage = IndexedDBStorage, NetworkListener = WindowNetworkListener, versions) { var _a; this._isOnline = true; this._isShutdown = false; this.status = STATUS.CONNECTING; /** @type {Record<string, Array<{ q: any, cb: (data: any) => any }>>} */ this.queryCbs = {}; /** @type {Record<string, Array<{ q: any, eventId: string, dfd: Deferred }>>} */ this.queryOnceDfds = {}; this.authCbs = []; this.attrsCbs = []; this.mutationErrorCbs = []; this.connectionStatusCbs = []; this.mutationDeferredStore = new Map(); this._reconnectTimeoutId = null; this._reconnectTimeoutMs = 0; this._localIdPromises = {}; this._errorMessage = null; /** @type {Promise<null | {error: {message: string}}>}**/ this._oauthCallbackResponse = null; /** @type {null | import('./utils/linkIndex.ts').LinkIndex}} */ this._linkIndex = null; /** @type {Record<string, {isConnected: boolean; error: any}>} */ this._rooms = {}; /** @type {Record<string, boolean>} */ this._roomsPendingLeave = {}; this._presence = {}; this._broadcastQueue = []; this._broadcastSubs = {}; this._currentUserCached = { isLoading: true, error: undefined, user: undefined }; this._beforeUnloadCbs = []; this._dataForQueryCache = {}; /** * merge querySubs from storage and in memory. Has the following side * effects: * - We notify all queryCbs because results may been added during merge */ this._onMergeQuerySubs = (_storageSubs, inMemorySubs) => { const storageSubs = _storageSubs || {}; const ret = Object.assign({}, inMemorySubs); // Consider an inMemorySub with no result; // If we have a result from storageSubs, let's add it Object.entries(inMemorySubs).forEach(([hash, querySub]) => { var _a; const storageResult = (_a = storageSubs === null || storageSubs === void 0 ? void 0 : storageSubs[hash]) === null || _a === void 0 ? void 0 : _a.result; const memoryResult = querySub.result; if (storageResult && !memoryResult) { ret[hash].result = storageResult; } }); // Consider a storageSub with no corresponding inMemorySub // This means that at least at this point, // the user has not asked to subscribe to the query. // We may _still_ want to add it, because in just a // few milliseconds, the user will ask to subscribe to the // query. // For now, we can't really tell if the user will ask to subscribe // or not. So for now let's just add the first 10 queries from storage. // Eventually, we could be smarter about this. For example, // we can keep usage information about which queries are popular. const storageKsToAdd = Object.keys(storageSubs) .filter((k) => !inMemorySubs[k]) .sort((a, b) => { var _a, _b; // Sort by lastAccessed, newest first const aTime = ((_a = storageSubs[a]) === null || _a === void 0 ? void 0 : _a.lastAccessed) || 0; const bTime = ((_b = storageSubs[b]) === null || _b === void 0 ? void 0 : _b.lastAccessed) || 0; return bTime - aTime; }) .slice(0, this.queryCacheLimit); storageKsToAdd.forEach((k) => { ret[k] = storageSubs[k]; }); // Okay, now we have merged our querySubs this.querySubs.set((_) => ret); this.loadedNotifyAll(); }; /** * merge pendingMutations from storage and in memory. Has a side effect of * sending mutations that were stored but not acked */ this._onMergePendingMutations = (storageMuts, inMemoryMuts) => { const ret = new Map([...storageMuts.entries(), ...inMemoryMuts.entries()]); this.pendingMutations.set((_) => ret); this.loadedNotifyAll(); const rewrittenStorageMuts = this._rewriteMutationsSorted(this.attrs, storageMuts); rewrittenStorageMuts.forEach(([k, mut]) => { if (!inMemoryMuts.has(k) && !mut['tx-id']) { this._sendMutation(k, mut); } }); }; // --------------------------- // Queries this.getPreviousResult = (q) => { const hash = weakHash(q); return this.dataForQuery(hash); }; /** Re-run instaql and call all callbacks with new data */ this.notifyOne = (hash) => { var _a, _b; const cbs = (_a = this.queryCbs[hash]) !== null && _a !== void 0 ? _a : []; const prevData = (_b = this._dataForQueryCache[hash]) === null || _b === void 0 ? void 0 : _b.data; const data = this.dataForQuery(hash); if (!data) return; if (areObjectsDeepEqual(data, prevData)) return; cbs.forEach((r) => r.cb(data)); }; this.notifyOneQueryOnce = (hash) => { var _a; const dfds = (_a = this.queryOnceDfds[hash]) !== null && _a !== void 0 ? _a : []; const data = this.dataForQuery(hash); dfds.forEach((r) => { this._completeQueryOnce(r.q, hash, r.dfd); r.dfd.resolve(data); }); }; this.notifyQueryError = (hash, error) => { const cbs = this.queryCbs[hash] || []; cbs.forEach((r) => r.cb({ error })); }; /** Applies transactions locally and sends transact message to server */ this.pushTx = (chunks) => { try { const txSteps = instaml.transform({ attrs: this.optimisticAttrs(), schema: this.config.schema, stores: Object.values(this.querySubs.currentValue).map((sub) => { var _a; return (_a = sub === null || sub === void 0 ? void 0 : sub.result) === null || _a === void 0 ? void 0 : _a.store; }), }, chunks); return this.pushOps(txSteps); } catch (e) { return this.pushOps([], e); } }; /** * @param {*} txSteps * @param {*} [error] * @returns */ this.pushOps = (txSteps, error) => { const eventId = uuid(); const mutations = [...this.pendingMutations.currentValue.values()]; const order = Math.max(0, ...mutations.map((mut) => mut.order || 0)) + 1; const mutation = { op: 'transact', 'tx-steps': txSteps, created: Date.now(), error, order, }; this.pendingMutations.set((prev) => { prev.set(eventId, mutation); return prev; }); const dfd = new Deferred(); this.mutationDeferredStore.set(eventId, dfd); this._sendMutation(eventId, mutation); this.notifyAll(); return dfd.promise; }; this._wsOnOpen = (e) => { const targetWs = e.target; if (this._ws !== targetWs) { this._log.info('[socket][open]', targetWs._id, 'skip; this is no longer the current ws'); return; } this._log.info('[socket][open]', this._ws._id); this._setStatus(STATUS.OPENED); this.getCurrentUser() .then((resp) => { var _a; this._trySend(uuid(), { op: 'init', 'app-id': this.config.appId, 'refresh-token': (_a = resp.user) === null || _a === void 0 ? void 0 : _a['refresh_token'], versions: this.versions, // If an admin token is provided for an app, we will // skip all permission checks. This is an advanced feature, // to let users write internal tools // This option is not exposed in `Config`, as it's // not ready for prime time '__admin-token': this.config.__adminToken, }); }) .catch((e) => { this._log.error('[socket][error]', targetWs._id, e); }); }; this._wsOnMessage = (e) => { const targetWs = e.target; const m = JSON.parse(e.data.toString()); if (this._ws !== targetWs) { this._log.info('[socket][message]', targetWs._id, m, 'skip; this is no longer the current ws'); return; } this._handleReceive(targetWs._id, JSON.parse(e.data.toString())); }; this._wsOnError = (e) => { const targetWs = e.target; if (this._ws !== targetWs) { this._log.info('[socket][error]', targetWs._id, 'skip; this is no longer the current ws'); return; } this._log.error('[socket][error]', targetWs._id, e); }; this._wsOnClose = (e) => { const targetWs = e.target; if (this._ws !== targetWs) { this._log.info('[socket][close]', targetWs._id, 'skip; this is no longer the current ws'); return; } this._setStatus(STATUS.CLOSED); for (const room of Object.values(this._rooms)) { room.isConnected = false; } if (this._isShutdown) { this._log.info('[socket][close]', targetWs._id, 'Reactor has been shut down and will not reconnect'); return; } this._log.info('[socket][close]', targetWs._id, 'schedule reconnect, ms =', this._reconnectTimeoutMs); setTimeout(() => { this._reconnectTimeoutMs = Math.min(this._reconnectTimeoutMs + 1000, 10000); if (!this._isOnline) { this._log.info('[socket][close]', targetWs._id, 'we are offline, no need to start socket'); return; } this._startSocket(); }, this._reconnectTimeoutMs); }; this.config = Object.assign(Object.assign({}, defaultConfig), config); this.queryCacheLimit = (_a = this.config.queryCacheLimit) !== null && _a !== void 0 ? _a : 10; this._log = createLogger(config.verbose || flags.devBackend || flags.instantLogs); this.versions = Object.assign(Object.assign({}, (versions || {})), { '@instantdb/core': version }); if (this.config.schema) { this._linkIndex = createLinkIndex(this.config.schema); } // This is to protect us against running // server-side. if (!isClient()) { return; } if (typeof BroadcastChannel === 'function') { this._broadcastChannel = new BroadcastChannel('@instantdb'); this._broadcastChannel.addEventListener('message', (e) => __awaiter(this, void 0, void 0, function* () { var _a; try { if (((_a = e.data) === null || _a === void 0 ? void 0 : _a.type) === 'auth') { const res = yield this.getCurrentUser(); this.updateUser(res.user); } } catch (e) { this._log.error('[error] handle broadcast channel', e); } })); } this._oauthCallbackResponse = this._oauthLoginInit(); this._initStorage(Storage); // kick off a request to cache it this.getCurrentUser(); NetworkListener.getIsOnline().then((isOnline) => { this._isOnline = isOnline; this._startSocket(); NetworkListener.listen((isOnline) => { // We do this because react native's NetInfo // fires multiple online events. // We only want to handle one state change if (isOnline === this._isOnline) { return; } this._log.info('[network] online =', isOnline); this._isOnline = isOnline; if (this._isOnline) { this._startSocket(); } else { this._log.info('Changing status from', this.status, 'to', STATUS.CLOSED); this._setStatus(STATUS.CLOSED); } }); }); if (typeof addEventListener !== 'undefined') { this._beforeUnload = this._beforeUnload.bind(this); addEventListener('beforeunload', this._beforeUnload); } } updateSchema(schema) { this.config = Object.assign(Object.assign({}, this.config), { schema: schema, cardinalityInference: Boolean(schema) }); this._linkIndex = schema ? createLinkIndex(this.config.schema) : null; } _initStorage(Storage) { this._persister = new Storage(`instant_${this.config.appId}_5`); this.querySubs = new PersistedObject(this._persister, 'querySubs', {}, this._onMergeQuerySubs, querySubsToJSON, querySubsFromJSON); this.pendingMutations = new PersistedObject(this._persister, 'pendingMutations', new Map(), this._onMergePendingMutations, (x) => { return JSON.stringify([...x.entries()]); }, (x) => { return new Map(JSON.parse(x)); }); this._beforeUnloadCbs.push(() => { this.pendingMutations.flush(); this.querySubs.flush(); }); } _beforeUnload() { for (const cb of this._beforeUnloadCbs) { cb(); } } /** * @param {'enqueued' | 'pending' | 'synced' | 'timeout' | 'error' } status * @param string eventId * @param {{message?: string, hint?: string, error?: Error}} [errDetails] */ _finishTransaction(status, eventId, errDetails) { const dfd = this.mutationDeferredStore.get(eventId); this.mutationDeferredStore.delete(eventId); const ok = status !== 'error' && status !== 'timeout'; if (!dfd && !ok) { // console.erroring here, as there are no listeners to let know console.error('Mutation failed', Object.assign({ status, eventId }, errDetails)); } if (!dfd) { return; } if (ok) { dfd.resolve({ status, eventId }); } else { dfd.reject(Object.assign({ status, eventId }, errDetails)); } } _setStatus(status, err) { this.status = status; this._errorMessage = err; this.notifyConnectionStatusSubs(status); } _flushEnqueuedRoomData(roomId) { var _a, _b; const enqueuedUserPresence = (_b = (_a = this._presence[roomId]) === null || _a === void 0 ? void 0 : _a.result) === null || _b === void 0 ? void 0 : _b.user; const enqueuedBroadcasts = this._broadcastQueue[roomId]; this._broadcastQueue[roomId] = []; if (enqueuedUserPresence) { this._trySetPresence(roomId, enqueuedUserPresence); } if (enqueuedBroadcasts) { for (const item of enqueuedBroadcasts) { const { topic, roomType, data } = item; this._tryBroadcast(roomId, roomType, topic, data); } } } _handleReceive(wsId, msg) { var _a, _b, _c, _d, _e, _f; // opt-out, enabled by default if schema const enableCardinalityInference = Boolean(this.config.schema) && ('cardinalityInference' in this.config ? Boolean(this.config.cardinalityInference) : true); if (!ignoreLogging[msg.op]) { this._log.info('[receive]', wsId, msg.op, msg); } switch (msg.op) { case 'init-ok': this._setStatus(STATUS.AUTHENTICATED); this._reconnectTimeoutMs = 0; this._setAttrs(msg.attrs); this._flushPendingMessages(); // (EPH): set session-id, so we know // which item is us this._sessionId = msg['session-id']; for (const roomId of Object.keys(this._rooms)) { const enqueuedUserPresence = (_b = (_a = this._presence[roomId]) === null || _a === void 0 ? void 0 : _a.result) === null || _b === void 0 ? void 0 : _b.user; this._tryJoinRoom(roomId, enqueuedUserPresence); } break; case 'add-query-exists': this.notifyOneQueryOnce(weakHash(msg.q)); break; case 'add-query-ok': const { q, result } = msg; const hash = weakHash(q); const pageInfo = (_d = (_c = result === null || result === void 0 ? void 0 : result[0]) === null || _c === void 0 ? void 0 : _c.data) === null || _d === void 0 ? void 0 : _d['page-info']; const aggregate = (_f = (_e = result === null || result === void 0 ? void 0 : result[0]) === null || _e === void 0 ? void 0 : _e.data) === null || _f === void 0 ? void 0 : _f['aggregate']; const triples = extractTriples(result); const store = s.createStore(this.attrs, triples, enableCardinalityInference, this._linkIndex); this.querySubs.set((prev) => { prev[hash].result = { store, pageInfo, aggregate, processedTxId: msg['processed-tx-id'], }; return prev; }); this._cleanupPendingMutationsQueries(); this.notifyOne(hash); this.notifyOneQueryOnce(hash); this._cleanupPendingMutationsTimeout(); break; case 'refresh-ok': const { computations, attrs } = msg; const processedTxId = msg['processed-tx-id']; if (attrs) { this._setAttrs(attrs); } this._cleanupPendingMutationsTimeout(); const rewrittenMutations = this._rewriteMutations(this.attrs, this.pendingMutations.currentValue, processedTxId); if (rewrittenMutations !== this.pendingMutations.currentValue) { // We know we've changed the mutations to fix the attr ids and removed // processed attrs, so we'll persist those changes to prevent optimisticAttrs // from using old attr definitions this.pendingMutations.set(() => rewrittenMutations); } const mutations = sortedMutationEntries(rewrittenMutations.entries()); const updates = computations.map((x) => { var _a, _b, _c, _d; const q = x['instaql-query']; const result = x['instaql-result']; const hash = weakHash(q); const triples = extractTriples(result); const store = s.createStore(this.attrs, triples, enableCardinalityInference, this._linkIndex); const newStore = this._applyOptimisticUpdates(store, mutations, processedTxId); const pageInfo = (_b = (_a = result === null || result === void 0 ? void 0 : result[0]) === null || _a === void 0 ? void 0 : _a.data) === null || _b === void 0 ? void 0 : _b['page-info']; const aggregate = (_d = (_c = result === null || result === void 0 ? void 0 : result[0]) === null || _c === void 0 ? void 0 : _c.data) === null || _d === void 0 ? void 0 : _d['aggregate']; return { hash, store: newStore, pageInfo, aggregate }; }); updates.forEach(({ hash, store, pageInfo, aggregate }) => { this.querySubs.set((prev) => { prev[hash].result = { store, pageInfo, aggregate, processedTxId }; return prev; }); }); this._cleanupPendingMutationsQueries(); updates.forEach(({ hash }) => { this.notifyOne(hash); }); break; case 'transact-ok': const { 'client-event-id': eventId, 'tx-id': txId } = msg; const muts = this._rewriteMutations(this.attrs, this.pendingMutations.currentValue); const prevMutation = muts.get(eventId); if (!prevMutation) { break; } // update pendingMutation with server-side tx-id this.pendingMutations.set((prev) => { prev.set(eventId, Object.assign(Object.assign({}, prev.get(eventId)), { 'tx-id': txId, confirmed: Date.now() })); return prev; }); this._cleanupPendingMutationsTimeout(); const newAttrs = prevMutation['tx-steps'] .filter(([action, ..._args]) => action === 'add-attr') .map(([_action, attr]) => attr) .concat(Object.values(this.attrs)); this._setAttrs(newAttrs); this._finishTransaction('synced', eventId); break; case 'patch-presence': { const roomId = msg['room-id']; this._patchPresencePeers(roomId, msg['edits']); this._notifyPresenceSubs(roomId); break; } case 'refresh-presence': { const roomId = msg['room-id']; this._setPresencePeers(roomId, msg['data']); this._notifyPresenceSubs(roomId); break; } case 'server-broadcast': const room = msg['room-id']; const topic = msg.topic; this._notifyBroadcastSubs(room, topic, msg); break; case 'join-room-ok': const loadingRoomId = msg['room-id']; const joinedRoom = this._rooms[loadingRoomId]; if (!joinedRoom) { if (this._roomsPendingLeave[loadingRoomId]) { this._tryLeaveRoom(loadingRoomId); delete this._roomsPendingLeave[loadingRoomId]; } break; } joinedRoom.isConnected = true; this._notifyPresenceSubs(loadingRoomId); this._flushEnqueuedRoomData(loadingRoomId); break; case 'join-room-error': const errorRoomId = msg['room-id']; const errorRoom = this._rooms[errorRoomId]; if (errorRoom) { errorRoom.error = msg['error']; } this._notifyPresenceSubs(errorRoomId); break; case 'error': this._handleReceiveError(msg); break; default: break; } } /** * @param {'timeout' | 'error'} status * @param {string} eventId * @param {{message?: string, hint?: string, error?: Error}} errDetails */ _handleMutationError(status, eventId, errDetails) { const mut = this.pendingMutations.currentValue.get(eventId); if (mut && (status !== 'timeout' || !mut['tx-id'])) { this.pendingMutations.set((prev) => { prev.delete(eventId); return prev; }); this.notifyAll(); this.notifyAttrsSubs(); this.notifyMutationErrorSubs(errDetails); this._finishTransaction(status, eventId, errDetails); } } _handleReceiveError(msg) { var _a, _b, _c, _d, _e; const eventId = msg['client-event-id']; const prevMutation = this.pendingMutations.currentValue.get(eventId); const errorMessage = { message: msg.message || 'Uh-oh, something went wrong. Ping Joe & Stopa.', }; if (msg.hint) { errorMessage.hint = msg.hint; } if (prevMutation) { // This must be a transaction error const errDetails = { message: msg.message, hint: msg.hint, }; this._handleMutationError('error', eventId, errDetails); return; } if (((_a = msg['original-event']) === null || _a === void 0 ? void 0 : _a.hasOwnProperty('q')) && ((_b = msg['original-event']) === null || _b === void 0 ? void 0 : _b.op) === 'add-query') { const q = (_c = msg['original-event']) === null || _c === void 0 ? void 0 : _c.q; const hash = weakHash(q); this.notifyQueryError(weakHash(q), errorMessage); this.notifyQueryOnceError(q, hash, eventId, errorMessage); return; } const isInitError = ((_d = msg['original-event']) === null || _d === void 0 ? void 0 : _d.op) === 'init'; if (isInitError) { if (msg.type === 'record-not-found' && ((_e = msg.hint) === null || _e === void 0 ? void 0 : _e['record-type']) === 'app-user') { // User has been logged out this.changeCurrentUser(null); return; } // We failed to init this._setStatus(STATUS.ERRORED, errorMessage); this.notifyAll(); return; } // We've caught some error which has no corresponding listener. // Let's console.error to let the user know. const errorObj = Object.assign({}, msg); delete errorObj.message; delete errorObj.hint; console.error(msg.message, errorObj); if (msg.hint) { console.error('This error comes with some debugging information. Here it is: \n', msg.hint); } } notifyQueryOnceError(q, hash, eventId, e) { var _a; const r = (_a = this.queryOnceDfds[hash]) === null || _a === void 0 ? void 0 : _a.find((r) => r.eventId === eventId); if (!r) return; r.dfd.reject(e); this._completeQueryOnce(q, hash, r.dfd); } _setAttrs(attrs) { this.attrs = attrs.reduce((acc, attr) => { acc[attr.id] = attr; return acc; }, {}); this.notifyAttrsSubs(); } _startQuerySub(q, hash) { const eventId = uuid(); this.querySubs.set((prev) => { prev[hash] = prev[hash] || { q, result: null, eventId }; prev[hash].lastAccessed = Date.now(); return prev; }); this._trySendAuthed(eventId, { op: 'add-query', q }); return eventId; } /** * When a user subscribes to a query the following side effects occur: * * - We update querySubs to include the new query * - We update queryCbs to include the new cb * - If we already have a result for the query we call cb immediately * - We send the server an `add-query` message * * Returns an unsubscribe function */ subscribeQuery(q, cb, opts) { var _a; if (opts && 'ruleParams' in opts) { q = Object.assign({ $$ruleParams: opts['ruleParams'] }, q); } const hash = weakHash(q); const prevResult = this.getPreviousResult(q); if (prevResult) { cb(prevResult); } this.queryCbs[hash] = (_a = this.queryCbs[hash]) !== null && _a !== void 0 ? _a : []; this.queryCbs[hash].push({ q, cb }); this._startQuerySub(q, hash); return () => { this._unsubQuery(q, hash, cb); }; } queryOnce(q, opts) { var _a; if (opts && 'ruleParams' in opts) { q = Object.assign({ $$ruleParams: opts['ruleParams'] }, q); } const dfd = new Deferred(); if (!this._isOnline) { dfd.reject(new Error("We can't run `queryOnce`, because the device is offline.")); return dfd.promise; } if (!this.querySubs) { dfd.reject(new Error("We can't run `queryOnce` on the backend. Use adminAPI.query instead: https://www.instantdb.com/docs/backend#query")); return dfd.promise; } const hash = weakHash(q); const eventId = this._startQuerySub(q, hash); this.queryOnceDfds[hash] = (_a = this.queryOnceDfds[hash]) !== null && _a !== void 0 ? _a : []; this.queryOnceDfds[hash].push({ q, dfd, eventId }); setTimeout(() => dfd.reject(new Error('Query timed out')), QUERY_ONCE_TIMEOUT); return dfd.promise; } _completeQueryOnce(q, hash, dfd) { if (!this.queryOnceDfds[hash]) return; this.queryOnceDfds[hash] = this.queryOnceDfds[hash].filter((r) => r.dfd !== dfd); this._cleanupQuery(q, hash); } _unsubQuery(q, hash, cb) { if (!this.queryCbs[hash]) return; this.queryCbs[hash] = this.queryCbs[hash].filter((r) => r.cb !== cb); this._cleanupQuery(q, hash); } _cleanupQuery(q, hash) { var _a, _b; const hasListeners = ((_a = this.queryCbs[hash]) === null || _a === void 0 ? void 0 : _a.length) || ((_b = this.queryOnceDfds[hash]) === null || _b === void 0 ? void 0 : _b.length); if (hasListeners) return; delete this.queryCbs[hash]; delete this.queryOnceDfds[hash]; this._trySendAuthed(uuid(), { op: 'remove-query', q }); } // When we `pushTx`, it's possible that we don't yet have `this.attrs` // This means that `tx-steps` in `pendingMutations` will include `add-attr` // commands for attrs that already exist. // // This will also affect `add-triple` and `retract-triple` which // reference attr-ids that do not match the server. // // We fix this by rewriting `tx-steps` in each `pendingMutation`. // We remove `add-attr` commands for attrs that already exist. // We update `add-triple` and `retract-triple` commands to use the // server attr-ids. _rewriteMutations(attrs, muts, processedTxId) { if (!attrs) return muts; const findExistingAttr = (attr) => { const [_, etype, label] = attr['forward-identity']; const existing = instaml.getAttrByFwdIdentName(attrs, etype, label); return existing; }; const findReverseAttr = (attr) => { const [_, etype, label] = attr['forward-identity']; const revAttr = instaml.getAttrByReverseIdentName(attrs, etype, label); return revAttr; }; const mapping = { attrIdMap: {}, refSwapAttrIds: new Set() }; let mappingChanged = false; const rewriteTxSteps = (txSteps, txId) => { const retTxSteps = []; for (const txStep of txSteps) { const [action] = txStep; // Handles add-attr // If existing, we drop it, and track it // to update add/retract triples if (action === 'add-attr') { const [_action, attr] = txStep; const existing = findExistingAttr(attr); if (existing && attr.id !== existing.id) { mapping.attrIdMap[attr.id] = existing.id; mappingChanged = true; continue; } if (attr['value-type'] === 'ref') { const revAttr = findReverseAttr(attr); if (revAttr) { mapping.attrIdMap[attr.id] = revAttr.id; mapping.refSwapAttrIds.add(attr.id); mappingChanged = true; continue; } } } if ((processedTxId && txId && processedTxId >= txId && action === 'add-attr') || action === 'update-attr' || action === 'delete-attr') { mappingChanged = true; // Don't add this step because we already have the newer attrs continue; } // Handles add-triple|retract-triple // If in mapping, we update the attr-id const newTxStep = mappingChanged ? instaml.rewriteStep(mapping, txStep) : txStep; retTxSteps.push(newTxStep); } return mappingChanged ? retTxSteps : txSteps; }; const rewritten = new Map(); for (const [k, mut] of muts.entries()) { rewritten.set(k, Object.assign(Object.assign({}, mut), { 'tx-steps': rewriteTxSteps(mut['tx-steps'], mut['tx-id']) })); } if (!mappingChanged) { return muts; } return rewritten; } _rewriteMutationsSorted(attrs, muts) { return sortedMutationEntries(this._rewriteMutations(attrs, muts).entries()); } // --------------------------- // Transact optimisticAttrs() { var _a; const pendingMutationSteps = [ ...this.pendingMutations.currentValue.values(), ] // hack due to Map() .flatMap((x) => x['tx-steps']); const deletedAttrIds = new Set(pendingMutationSteps .filter(([action, _attr]) => action === 'delete-attr') .map(([_action, id]) => id)); const pendingAttrs = []; for (const [_action, attr] of pendingMutationSteps) { if (_action === 'add-attr') { pendingAttrs.push(attr); } else if (_action === 'update-attr' && attr.id && ((_a = this.attrs) === null || _a === void 0 ? void 0 : _a[attr.id])) { const fullAttr = Object.assign(Object.assign({}, this.attrs[attr.id]), attr); pendingAttrs.push(fullAttr); } } const attrsWithoutDeleted = [ ...Object.values(this.attrs || {}), ...pendingAttrs, ].filter((a) => !deletedAttrIds.has(a.id)); const attrsRecord = Object.fromEntries(attrsWithoutDeleted.map((a) => [a.id, a])); return attrsRecord; } /** Runs instaql on a query and a store */ dataForQuery(hash) { const errorMessage = this._errorMessage; if (errorMessage) { return { error: errorMessage }; } if (!this.querySubs) return; if (!this.pendingMutations) return; const querySubVersion = this.querySubs.version(); const querySubs = this.querySubs.currentValue; const pendingMutationsVersion = this.pendingMutations.version(); const pendingMutations = this.pendingMutations.currentValue; const { q, result } = querySubs[hash] || {}; if (!result) return; const cached = this._dataForQueryCache[hash]; if (cached && querySubVersion === cached.querySubVersion && pendingMutationsVersion === cached.pendingMutationsVersion) { return cached.data; } const { store, pageInfo, aggregate, processedTxId } = result; const mutations = this._rewriteMutationsSorted(store.attrs, pendingMutations); const newStore = this._applyOptimisticUpdates(store, mutations, processedTxId); const resp = instaql({ store: newStore, pageInfo, aggregate }, q); this._dataForQueryCache[hash] = { querySubVersion, pendingMutationsVersion, data: resp, }; return resp; } _applyOptimisticUpdates(store, mutations, processedTxId) { for (const [_, mut] of mutations) { if (!mut['tx-id'] || (processedTxId && mut['tx-id'] > processedTxId)) { store = s.transact(store, mut['tx-steps']); } } return store; } /** Re-compute all subscriptions */ notifyAll() { Object.keys(this.queryCbs).forEach((hash) => { this.notifyOne(hash); }); } loadedNotifyAll() { if (this.pendingMutations.isLoading() || this.querySubs.isLoading()) return; this.notifyAll(); } shutdown() { var _a; this._log.info('[shutdown]', this.config.appId); this._isShutdown = true; (_a = this._ws) === null || _a === void 0 ? void 0 : _a.close(); } /** * Sends mutation to server and schedules a timeout to cancel it if * we don't hear back in time. * Note: If we're offline we don't schedule a timeout, we'll schedule it * later once we're back online and send the mutation again * */ _sendMutation(eventId, mutation) { if (mutation.error) { this._handleMutationError('error', eventId, { error: mutation.error, message: mutation.error.message, }); return; } if (this.status !== STATUS.AUTHENTICATED) { this._finishTransaction('enqueued', eventId); return; } const timeoutMs = Math.max(5000, this.pendingMutations.currentValue.size * 5000); if (!this._isOnline) { this._finishTransaction('enqueued', eventId); } else { this._trySend(eventId, mutation); setTimeout(() => { if (!this._isOnline) { return; } // If we are here, this means that we have sent this mutation, we are online // but we have not received a response. If it's this long, something must be wrong, // so we error with a timeout. this._handleMutationError('timeout', eventId, { message: 'transaction timed out', }); }, timeoutMs); } } // --------------------------- // Websocket /** Send messages we accumulated while we were connecting */ _flushPendingMessages() { const subs = Object.keys(this.queryCbs).map((hash) => { return this.querySubs.currentValue[hash]; }); // Note: we should not have any nulls in subs, but we're // doing this defensively just in case. const safeSubs = subs.filter((x) => x); safeSubs.forEach(({ eventId, q }) => { this._trySendAuthed(eventId, { op: 'add-query', q }); }); Object.values(this.queryOnceDfds) .flat() .forEach(({ eventId, q }) => { this._trySendAuthed(eventId, { op: 'add-query', q }); }); const muts = this._rewriteMutationsSorted(this.attrs, this.pendingMutations.currentValue); muts.forEach(([eventId, mut]) => { if (!mut['tx-id']) { this._sendMutation(eventId, mut); } }); } /** * Clean up pendingMutations that all queries have seen */ _cleanupPendingMutationsQueries() { let minProcessedTxId = Number.MAX_SAFE_INTEGER; for (const { result } of Object.values(this.querySubs.currentValue)) { if (result === null || result === void 0 ? void 0 : result.processedTxId) { minProcessedTxId = Math.min(minProcessedTxId, result === null || result === void 0 ? void 0 : result.processedTxId); } } this.pendingMutations.set((prev) => { for (const [eventId, mut] of Array.from(prev.entries())) { if (mut['tx-id'] && mut['tx-id'] <= minProcessedTxId) { prev.delete(eventId); } } return prev; }); } /** * After mutations is confirmed by server, we give each query 30 sec * to update its results. If that doesn't happen, we assume query is * unaffected by this mutation and it’s safe to delete it from local queue */ _cleanupPendingMutationsTimeout() { const now = Date.now(); if (this.pendingMutations.currentValue.size < 200) { return; } this.pendingMutations.set((prev) => { let deleted = false; let timeless = false; for (const [eventId, mut] of Array.from(prev.entries())) { if (!mut.confirmed) { timeless = true; } if (mut.confirmed && mut.confirmed + PENDING_TX_CLEANUP_TIMEOUT < now) { prev.delete(eventId); deleted = true; } } // backwards compat for mutations with no `confirmed` if (deleted && timeless) { for (const [eventId, mut] of Array.from(prev.entries())) { if (!mut.confirmed) { prev.delete(eventId); } } } return prev; }); } _trySendAuthed(...args) { if (this.status !== STATUS.AUTHENTICATED) { return; } this._trySend(...args); } _trySend(eventId, msg, opts) { if (this._ws.readyState !== WS_OPEN_STATUS) { return; } if (!ignoreLogging[msg.op]) { this._log.info('[send]', this._ws._id, msg.op, msg); } this._ws.send(JSON.stringify(Object.assign({ 'client-event-id': eventId }, msg))); } _startSocket() { if (this._isShutdown) { this._log.info('[socket][start]', this.config.appId, 'Reactor has been shut down and will not start a new socket'); return; } if (this._ws && this._ws.readyState == WS_CONNECTING_STATUS) { // Our current websocket is in a 'connecting' state. // There's no need to start another one, as the socket is // effectively fresh. this._log.info('[socket][start]', this._ws._id, 'maintained as current ws, we were still in a connecting state'); return; } const prevWs = this._ws; this._ws = createWebSocket(`${this.config.websocketURI}?app_id=${this.config.appId}`); this._ws.onopen = this._wsOnOpen; this._ws.onmessage = this._wsOnMessage; this._ws.onclose = this._wsOnClose; this._ws.onerror = this._wsOnError; this._log.info('[socket][start]', this._ws._id); if ((prevWs === null || prevWs === void 0 ? void 0 : prevWs.readyState) === WS_OPEN_STATUS) { // When the network dies, it doesn't always mean that our // socket connection will fire a close event. // // We _could_ re-use the old socket, if the network drop was a // few seconds. But, to be safe right now we always create a new socket. // // This means that we have to make sure to kill the previous one ourselves. // c.f https://issues.chromium.org/issues/41343684 this._log.info('[socket][start]', this._ws._id, 'close previous ws id = ', prevWs._id); prevWs.close(); } } /** * Given a key, returns a stable local id, unique to this device and app. * * This can be useful if you want to create guest ids for example. * * Note: If the user deletes their local storage, this id will change. * * We use this._localIdPromises to ensure that we only generate a local * id once, even if multiple callers call this function concurrently. */ getLocalId(name) { return __awaiter(this, void 0, void 0, function* () { const k = `localToken_${name}`; const id = yield this._persister.getItem(k); if (id) return id; if (this._localIdPromises[k]) { return this._localIdPromises[k]; } const newId = uuid(); this._localIdPromises[k] = this._persister .setItem(k, newId) .then(() => newId); return this._localIdPromises[k]; }); } // ---- // Auth _replaceUrlAfterOAuth() { if (typeof URL === 'undefined') { return; } const url = new URL(window.location.href); if (url.searchParams.get(OAUTH_REDIRECT_PARAM)) { const startUrl = url.toString(); url.searchParams.delete(OAUTH_REDIRECT_PARAM); url.searchParams.delete('code'); url.searchParams.delete('error'); const newPath = url.pathname + (url.searchParams.size ? '?' + url.searchParams : '') + url.hash; // Note: In next.js, this will revert to the old state if user navigates // back. We would need to allow framework specific routing to work // around that problem. history.replaceState(history.state, '', newPath);