@instantdb/core
Version:
Instant's core local abstraction
1,140 lines • 76.4 kB
JavaScript
"use strict";
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
var desc = Object.getOwnPropertyDescriptor(m, k);
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
desc = { enumerable: true, get: function() { return m[k]; } };
}
Object.defineProperty(o, k2, desc);
}) : (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
o[k2] = m[k];
}));
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
Object.defineProperty(o, "default", { enumerable: true, value: v });
}) : function(o, v) {
o["default"] = v;
});
var __importStar = (this && this.__importStar) || (function () {
var ownKeys = function(o) {
ownKeys = Object.getOwnPropertyNames || function (o) {
var ar = [];
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
return ar;
};
return ownKeys(o);
};
return function (mod) {
if (mod && mod.__esModule) return mod;
var result = {};
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
__setModuleDefault(result, mod);
return result;
};
})();
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());
});
};
var __importDefault = (this && this.__importDefault) || function (mod) {
return (mod && mod.__esModule) ? mod : { "default": mod };
};
Object.defineProperty(exports, "__esModule", { value: true });
// @ts-check
const weakHash_ts_1 = __importDefault(require("./utils/weakHash.js"));
const instaql_js_1 = __importDefault(require("./instaql.js"));
const instaml = __importStar(require("./instaml.js"));
const s = __importStar(require("./store.js"));
const uuid_ts_1 = __importDefault(require("./utils/uuid.js"));
const IndexedDBStorage_js_1 = __importDefault(require("./IndexedDBStorage.js"));
const WindowNetworkListener_js_1 = __importDefault(require("./WindowNetworkListener.js"));
const authAPI = __importStar(require("./authAPI.js"));
const StorageApi = __importStar(require("./StorageAPI.js"));
const flags = __importStar(require("./utils/flags.js"));
const presence_ts_1 = require("./presence.js");
const Deferred_js_1 = require("./utils/Deferred.js");
const PersistedObject_js_1 = require("./utils/PersistedObject.js");
const instaqlResult_js_1 = require("./model/instaqlResult.js");
const object_js_1 = require("./utils/object.js");
const linkIndex_ts_1 = require("./utils/linkIndex.js");
const version_js_1 = __importDefault(require("./version.js"));
const mutative_1 = require("mutative");
const log_ts_1 = __importDefault(require("./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 = {}]
*/
class Reactor {
constructor(config, Storage = IndexedDBStorage_js_1.default, NetworkListener = WindowNetworkListener_js_1.default, 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 = (0, weakHash_ts_1.default)(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 ((0, object_js_1.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 = (0, uuid_ts_1.default)();
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_js_1.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((0, uuid_ts_1.default)(), {
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 = (0, log_ts_1.default)(config.verbose || flags.devBackend || flags.instantLogs);
this.versions = Object.assign(Object.assign({}, (versions || {})), { '@instantdb/core': version_js_1.default });
if (this.config.schema) {
this._linkIndex = (0, linkIndex_ts_1.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 ? (0, linkIndex_ts_1.createLinkIndex)(this.config.schema) : null;
}
_initStorage(Storage) {
this._persister = new Storage(`instant_${this.config.appId}_5`);
this.querySubs = new PersistedObject_js_1.PersistedObject(this._persister, 'querySubs', {}, this._onMergeQuerySubs, querySubsToJSON, querySubsFromJSON);
this.pendingMutations = new PersistedObject_js_1.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((0, weakHash_ts_1.default)(msg.q));
break;
case 'add-query-ok':
const { q, result } = msg;
const hash = (0, weakHash_ts_1.default)(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 = (0, instaqlResult_js_1.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 = (0, weakHash_ts_1.default)(q);
const triples = (0, instaqlResult_js_1.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 = (0, weakHash_ts_1.default)(q);
this.notifyQueryError((0, weakHash_ts_1.default)(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 = (0, uuid_ts_1.default)();
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 = (0, weakHash_ts_1.default)(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_js_1.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 = (0, weakHash_ts_1.default)(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((0, uuid_ts_1.default)(), { 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 = (0, instaql_js_1.default)({ 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 se