@instantdb/core
Version:
Instant's core local abstraction
2,837 lines • 82.5 kB
JavaScript
// @ts-check
import weakHash from './utils/weakHash.ts';
import weakHashLegacy from './utils/weakHashLegacy.ts';
import instaql from './instaql.ts';
import * as instaml from './instaml.ts';
import * as s from './store.ts';
import uuid from './utils/id.ts';
import IndexedDBStorage from './IndexedDBStorage.ts';
import WindowNetworkListener from './WindowNetworkListener.js';
import * as authAPI from './authAPI.ts';
import * as StorageApi from './StorageAPI.ts';
import * as flags from './utils/flags.ts';
import { buildPresenceSlice, hasPresenceResponseChanged } from './presence.ts';
import { Deferred } from './utils/Deferred.ts';
import { PersistedObject, StoreInterface } from './utils/PersistedObject.ts';
import { extractTriples } from './model/instaqlResult.js';
import {
areObjectsDeepEqual,
assocInMutative,
dissocInMutative,
insertInMutative,
} from './utils/object.js';
import { createLinkIndex } from './utils/linkIndex.ts';
import version from './version.ts';
import { create } from 'mutative';
import createLogger from './utils/log.ts';
import { validateQuery } from './queryValidation.ts';
import { validateTransactions } from './transactionValidation.ts';
import { InstantError } from './InstantError.ts';
import { InstantAPIError } from './utils/fetch.ts';
import { validate as validateUUID } from 'uuid';
import { WSConnection, SSEConnection } from './Connection.ts';
import { SyncTable } from './SyncTable.ts';
import { InstantStream } from './Stream.ts';
/** @typedef {import('./utils/log.ts').Logger} Logger */
/** @typedef {import('./Connection.ts').Connection} Connection */
/** @typedef {import('./Connection.ts').TransportType} TransportType */
/** @typedef {import('./Connection.ts').EventSourceConstructor} EventSourceConstructor */
/** @typedef {import('./reactorTypes.ts').QuerySub} QuerySub */
/** @typedef {import('./reactorTypes.ts').QuerySubInStorage} QuerySubInStorage */
/** @typedef {import('./clientTypes.ts').User} User */
/** @typedef {import('./clientTypes.ts').AuthState} AuthState */
/** @typedef {import('./framework.ts').FrameworkClient} FrameworkClient */
export const STATUS = {
CONNECTING: 'connecting',
OPENED: 'opened',
AUTHENTICATED: 'authenticated',
CLOSED: 'closed',
ERRORED: 'errored',
};
const QUERY_ONCE_TIMEOUT = 30_000;
const PENDING_TX_CLEANUP_TIMEOUT = 30_000;
const PENDING_MUTATION_CLEANUP_THRESHOLD = 200;
const ONE_MIN_MS = 1_000 * 60;
const ONE_DAY_MS = ONE_MIN_MS * 60 * 24;
export const COOKIE_SYNC_LAST_UPDATED_KEY = 'lastSyncedUserCookie';
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 OAUTH_EXTRA_FIELDS_ID_PARAM = '_instant_extra_fields_id';
const oauthExtraFieldsKey = 'oauthExtraFields';
const currentUserKey = `currentUser`;
/**
* @param {Object} config
* @param {TransportType} config.transportType
* @param {string} config.appId
* @param {string} config.apiURI
* @param {string} config.wsURI
* @param {EventSourceConstructor} config.EventSourceImpl
* @returns {WSConnection | SSEConnection}
*/
function createTransport({
transportType,
appId,
apiURI,
wsURI,
EventSourceImpl,
}) {
if (!EventSourceImpl) {
return new WSConnection(`${wsURI}?app_id=${appId}`);
}
switch (transportType) {
case 'ws':
return new WSConnection(`${wsURI}?app_id=${appId}`);
case 'sse':
return new SSEConnection(
EventSourceImpl,
`${apiURI}/runtime/sse?app_id=${appId}`,
);
default:
throw new Error('Unknown transport type ' + transportType);
}
}
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,
};
/**
* @param {QuerySubInStorage} x
* @param {boolean | null} useDateObjects
* @returns {QuerySub}
*/
function querySubFromStorage(x, useDateObjects) {
const v = typeof x === 'string' ? JSON.parse(x) : x;
if (v?.result?.store) {
const attrsStore = s.attrsStoreFromJSON(
v.result.attrsStore,
v.result.store,
);
if (attrsStore) {
const storeJSON = v.result.store;
v.result.store = s.fromJSON(attrsStore, {
...storeJSON,
useDateObjects: useDateObjects,
});
v.result.attrsStore = attrsStore;
}
}
return v;
}
/**
*
* @param {string} _key
* @param {QuerySub} sub
* @returns QuerySubInStorage
*/
function querySubToStorage(_key, sub) {
const { result, ...rest } = sub;
const jsonSub = /** @type {import('./reactorTypes.ts').QuerySubInStorage} */ (
rest
);
if (result) {
/** @type {import('./reactorTypes.ts').QuerySubResultInStorage} */
const jsonResult = {
...result,
store: s.toJSON(result.store),
attrsStore: result.attrsStore.toJSON(),
};
jsonSub.result = jsonResult;
}
return jsonSub;
}
function kvFromStorage(key, x) {
switch (key) {
case 'pendingMutations':
return new Map(typeof x === 'string' ? JSON.parse(x) : x);
default:
return x;
}
}
function kvToStorage(key, x) {
switch (key) {
case 'pendingMutations':
return [...x.entries()];
default:
return x;
}
}
function onMergeQuerySub(_k, storageSub, inMemorySub) {
const storageResult = storageSub?.result;
const memoryResult = inMemorySub?.result;
if (storageResult && !memoryResult && inMemorySub) {
inMemorySub.result = storageResult;
}
return inMemorySub || storageSub;
}
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 {
/** @type {s.AttrsStore | undefined} */
attrs;
_isOnline = true;
_isShutdown = false;
status = STATUS.CONNECTING;
/** @type {PersistedObject<string, QuerySub, QuerySubInStorage>} */
querySubs;
/** @type {PersistedObject} */
kv;
/** @type {SyncTable} */
_syncTable;
/** @type {InstantStream} */
_instantStream;
/** @type {Record<string, Array<{ q: any, cb: (data: any) => any }>>} */
queryCbs = {};
/** @type {Record<string, Array<{ q: any, eventId: string, dfd: Deferred }>>} */
queryOnceDfds = {};
authCbs = [];
attrsCbs = [];
mutationErrorCbs = [];
connectionStatusCbs = [];
appStatusCbs = [];
/**
* Carries the raw server status off the public type: `disabled` is an
* operator-level state we don't surface to apps.
* @type {import('./clientTypes.ts').AppStatusState & { status?: 'active' | 'read-only' | 'disabled' }}
*/
_appStatusState = { isLoading: true, isReadOnly: undefined };
config;
mutationDeferredStore = new Map();
_reconnectTimeoutId = null;
_reconnectTimeoutMs = 0;
/** @type {Connection} */
_transport;
/** @type {TransportType} */
_transportType = 'ws';
/** @type {EventSourceConstructor} */
_EventSource;
/** @type {boolean | null} */
_wsOk = null;
_localIdPromises = {};
_errorMessage = null;
/** @type {Promise<null | {error: {message: string}}> | null}**/
_oauthCallbackResponse = null;
/** @type {null | import('./utils/linkIndex.ts').LinkIndex}} */
_linkIndex = null;
/** @type BroadcastChannel | undefined */
_broadcastChannel;
/** @type {Record<string, {roomType: string; isConnected: boolean; error: any}>} */
_rooms = {};
/** @type {Record<string, boolean>} */
_roomsPendingLeave = {};
_presence = {};
_broadcastQueue = [];
_broadcastSubs = {};
/** @type {{isLoading: boolean; error: any | undefined, user: any | undefined}} */
_currentUserCached = { isLoading: true, error: undefined, user: undefined };
_beforeUnloadCbs = [];
_dataForQueryCache = {};
/** @type {Logger} */
_log;
_pendingTxCleanupTimeout;
_pendingMutationCleanupThreshold;
_inFlightMutationEventIds = new Set();
/** @type FrameworkClient | null */
_frameworkClient = null;
constructor(
config,
Storage = IndexedDBStorage,
NetworkListener = WindowNetworkListener,
versions,
EventSourceConstructor,
) {
this._EventSource = EventSourceConstructor;
this.config = { ...defaultConfig, ...config };
this.queryCacheLimit = this.config.queryCacheLimit ?? 10;
this._pendingTxCleanupTimeout =
this.config.pendingTxCleanupTimeout ?? PENDING_TX_CLEANUP_TIMEOUT;
this._pendingMutationCleanupThreshold =
this.config.pendingMutationCleanupThreshold ??
PENDING_MUTATION_CLEANUP_THRESHOLD;
this._log = createLogger(
config.verbose || flags.devBackend || flags.instantLogs,
() => this._reactorStats(),
config.logger,
);
this.versions = { ...(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 (!config.appId) {
throw new Error('Instant must be initialized with an appId.');
}
if (!validateUUID(config.appId)) {
throw new Error(
`Instant must be initialized with a valid appId. \`${config.appId}\` is not a valid uuid.`,
);
}
if (typeof BroadcastChannel === 'function') {
this._broadcastChannel = new BroadcastChannel('@instantdb');
this._broadcastChannel.addEventListener('message', async (e) => {
try {
if (e.data?.type === 'auth') {
const res = await this.getCurrentUser({
forceReadFromStorage: true,
});
await this.updateUser(res.user).catch((error) => {
this._log.error('[error] update user', error);
});
}
} catch (e) {
this._log.error('[error] handle broadcast channel', e);
}
});
}
this._initStorage(Storage);
this._syncTable = new SyncTable(
this._trySendAuthed.bind(this),
new Storage(this.config.appId, 'syncSubs'),
{
useDateObjects: this.config.useDateObjects,
},
this._log,
(triples) => {
return s.createStore(
this.ensureAttrs(),
triples,
this.config.enableCardinalityInference,
this.config.useDateObjects,
);
},
() => this.ensureAttrs(),
);
this._instantStream = new InstantStream({
WStream: this.config.WritableStream || WritableStream,
RStream: this.config.ReadableStream || ReadableStream,
trySend: this._trySendAuthed.bind(this),
log: this._log,
});
this._oauthCallbackResponse = this._oauthLoginInit();
this.setupUserSyncTimer();
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);
}
}
getFrameworkClient() {
return this._frameworkClient;
}
/** @param {FrameworkClient} client */
setFrameworkClient(client) {
this._frameworkClient = client;
}
ensureAttrs() {
if (!this.attrs) {
throw new Error('attrs have not loaded.');
}
return this.attrs;
}
updateSchema(schema) {
this.config = {
...this.config,
schema: schema,
cardinalityInference: Boolean(schema),
};
this._linkIndex = schema ? createLinkIndex(this.config.schema) : null;
}
_reactorStats() {
return {
inFlightMutationCount: this._inFlightMutationEventIds.size,
storedMutationCount: this._pendingMutations().size,
transportType: this._transportType,
};
}
_onQuerySubLoaded(hash) {
this.kv
.waitForKeyToLoad('pendingMutations')
.then(() => this.notifyOne(hash));
}
_initStorage(Storage) {
this.querySubs = new PersistedObject({
persister: new Storage(this.config.appId, 'querySubs'),
merge: onMergeQuerySub,
serialize: querySubToStorage,
parse: (_key, x) => querySubFromStorage(x, this.config.useDateObjects),
// objectSize
objectSize: (x) => x?.result?.store?.triples?.length ?? 0,
logger: this._log,
preloadEntryCount: 10,
gc: {
maxAgeMs: 1000 * 60 * 60 * 24 * 7 * 52, // 1 year
maxEntries: 1000,
// Size of each query is the number of triples
maxSize: 1_000_000, // 1 million triples
},
});
this.querySubs.onKeyLoaded = (k) => this._onQuerySubLoaded(k);
this.kv = new PersistedObject({
persister: new Storage(this.config.appId, 'kv'),
merge: this._onMergeKv,
serialize: kvToStorage,
parse: kvFromStorage,
objectSize: () => 0,
logger: this._log,
saveThrottleMs: 100,
idleCallbackMaxWaitMs: 100,
// Don't GC the kv store
gc: null,
});
this.kv.onKeyLoaded = (k) => {
if (k === 'pendingMutations') {
this.notifyAll();
}
};
// Trigger immediate load for pendingMutations and currentUser
this.kv.waitForKeyToLoad('pendingMutations');
this.kv.waitForKeyToLoad(currentUserKey);
this._beforeUnloadCbs.push(() => {
this.kv.flush();
this.querySubs.flush();
});
}
_beforeUnload() {
for (const cb of this._beforeUnloadCbs) {
cb();
}
this._syncTable.beforeUnload();
}
/**
* @param {'enqueued' | 'pending' | 'synced' | 'timeout' | 'error' } status
* @param {string} eventId
* @param {{message?: string, type?: string, status?: number, hint?: unknown, traceId?: string}} [errorMsg]
*/
_finishTransaction(status, eventId, errorMsg) {
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', { status, eventId, ...errorMsg });
}
if (!dfd) {
return;
}
if (ok) {
dfd.resolve({ status, eventId });
} else {
// Check if error comes from server or client
if (errorMsg?.type) {
const { status, ...body } = errorMsg;
dfd.reject(
new InstantAPIError({
// @ts-expect-error body.type is not constant typed
body,
status: status ?? 0,
}),
);
} else {
dfd.reject(
new InstantError(
errorMsg?.message || 'Unknown error',
errorMsg?.hint,
errorMsg?.traceId,
),
);
}
}
}
_setStatus(status, err) {
this.status = status;
this._errorMessage = err;
this.notifyConnectionStatusSubs(status);
this._instantStream.onConnectionStatusChange(status);
}
_onMergeKv = (key, storageV, inMemoryV) => {
switch (key) {
case 'pendingMutations': {
const storageEntries = storageV?.entries() ?? [];
const inMemoryEntries = inMemoryV?.entries() ?? [];
const muts = new Map([...storageEntries, ...inMemoryEntries]);
const rewrittenStorageMuts = storageV
? this._rewriteMutationsSorted(this.attrs, storageV)
: [];
rewrittenStorageMuts.forEach(([k, mut]) => {
if (!inMemoryV?.pendingMutations?.has(k) && !mut['tx-id']) {
this._sendMutation(k, mut);
}
});
return muts;
}
default:
return inMemoryV || storageV;
}
};
_flushEnqueuedRoomData(roomId) {
const enqueuedUserPresence = this._presence[roomId]?.result?.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);
}
}
}
/**
* Does the same thing as add-query-ok
* but called as a result of receiving query info from ssr
* @param {any} q
* @param {{ triples: any; pageInfo: any; }} result
* @param {boolean} enableCardinalityInference
*/
_addQueryData(q, result, enableCardinalityInference) {
if (!this.attrs) {
throw new Error('Attrs in reactor have not been set');
}
const queryHash = weakHash(q);
const attrsStore = this.ensureAttrs();
const store = s.createStore(
this.attrs,
result.triples,
enableCardinalityInference,
this.config.useDateObjects,
);
this.querySubs.updateInPlace((prev) => {
prev[queryHash] = {
result: {
store,
attrsStore,
pageInfo: result.pageInfo,
processedTxId: undefined,
isExternal: true,
},
q,
};
});
this._cleanupPendingMutationsQueries();
this.notifyOne(queryHash);
this.notifyOneQueryOnce(queryHash);
this._cleanupPendingMutationsTimeout();
}
_handleReceive(connId, msg) {
// 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]', connId, msg.op, msg);
}
switch (msg.op) {
case 'init-ok': {
this._setStatus(STATUS.AUTHENTICATED);
this._reconnectTimeoutMs = 0;
this._setAppStatus(msg['app-status']?.status ?? 'active');
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 = this._presence[roomId]?.result?.user;
const roomType = this._rooms[roomId]?.roomType;
this._tryJoinRoom(roomType, 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);
if (!this._hasQueryListeners() && !this.querySubs.currentValue[hash]) {
break;
}
const pageInfo = result?.[0]?.data?.['page-info'];
const aggregate = result?.[0]?.data?.['aggregate'];
const triples = extractTriples(result);
const attrsStore = this.ensureAttrs();
const store = s.createStore(
attrsStore,
triples,
enableCardinalityInference,
this.config.useDateObjects,
);
this.querySubs.updateInPlace((prev) => {
if (!prev[hash]) {
this._log.info('Missing value in querySubs', { hash, q });
return;
}
prev[hash].result = {
store,
attrsStore,
pageInfo,
aggregate,
processedTxId: msg['processed-tx-id'],
};
});
this._cleanupPendingMutationsQueries();
this.notifyOne(hash);
this.notifyOneQueryOnce(hash);
this._cleanupPendingMutationsTimeout();
break;
}
case 'start-sync-ok': {
this._syncTable.onStartSyncOk(msg);
break;
}
case 'sync-load-batch': {
this._syncTable.onSyncLoadBatch(msg);
break;
}
case 'sync-init-finish': {
this._syncTable.onSyncInitFinish(msg);
break;
}
case 'sync-update-triples': {
this._syncTable.onSyncUpdateTriples(msg);
break;
}
case 'start-stream-ok': {
this._instantStream.onStartStreamOk(msg);
break;
}
case 'stream-flushed': {
this._instantStream.onStreamFlushed(msg);
break;
}
case 'append-failed': {
this._instantStream.onAppendFailed(msg);
break;
}
case 'stream-append': {
this._instantStream.onStreamAppend(msg);
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.ensureAttrs(),
this._pendingMutations(),
processedTxId,
);
if (rewrittenMutations !== this._pendingMutations()) {
// 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.kv.updateInPlace((prev) => {
prev.pendingMutations = rewrittenMutations;
});
}
const mutations = sortedMutationEntries(rewrittenMutations.entries());
const updates = computations.map((x) => {
const q = x['instaql-query'];
const result = x['instaql-result'];
const hash = weakHash(q);
const triples = extractTriples(result);
const attrsStore = this.ensureAttrs();
const store = s.createStore(
attrsStore,
triples,
enableCardinalityInference,
this.config.useDateObjects,
);
const { store: newStore, attrsStore: newAttrsStore } =
this._applyOptimisticUpdates(
store,
attrsStore,
mutations,
processedTxId,
);
const pageInfo = result?.[0]?.data?.['page-info'];
const aggregate = result?.[0]?.data?.['aggregate'];
return {
q,
hash,
store: newStore,
attrsStore: newAttrsStore,
pageInfo,
aggregate,
};
});
updates.forEach(
({ hash, q, store, attrsStore, pageInfo, aggregate }) => {
this.querySubs.updateInPlace((prev) => {
if (!prev[hash]) {
this._log.error('Missing value in querySubs', { hash, q });
return;
}
prev[hash].result = {
store,
attrsStore,
pageInfo,
aggregate,
processedTxId,
};
});
},
);
this._cleanupPendingMutationsQueries();
updates.forEach(({ hash }) => {
this.notifyOne(hash);
});
break;
}
case 'transact-ok': {
const { 'client-event-id': eventId, 'tx-id': txId } = msg;
this._inFlightMutationEventIds.delete(eventId);
const muts = this._rewriteMutations(
this.ensureAttrs(),
this._pendingMutations(),
);
const prevMutation = muts.get(eventId);
if (!prevMutation) {
break;
}
// update pendingMutation with server-side tx-id
this._updatePendingMutations((prev) => {
prev.set(eventId, {
...prev.get(eventId),
'tx-id': txId,
confirmed: Date.now(),
});
});
const newAttrs = [];
for (const step of prevMutation['tx-steps']) {
if (step[0] === 'add-attr') {
const attr = step[1];
newAttrs.push(attr);
}
}
if (newAttrs.length) {
const existingAttrs = Object.values(this.ensureAttrs().attrs);
this._setAttrs([...existingAttrs, ...newAttrs]);
}
this._finishTransaction('synced', eventId);
this._cleanupPendingMutationsTimeout();
break;
}
case 'patch-presence': {
const roomId = msg['room-id'];
this._trySetRoomConnected(roomId, true);
this._patchPresencePeers(roomId, msg['edits']);
this._notifyPresenceSubs(roomId);
break;
}
case 'refresh-presence': {
const roomId = msg['room-id'];
this._trySetRoomConnected(roomId, true);
this._setPresencePeers(roomId, msg['data']);
this._notifyPresenceSubs(roomId);
break;
}
case 'server-broadcast': {
const room = msg['room-id'];
const topic = msg.topic;
this._trySetRoomConnected(room, true);
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;
}
this._trySetRoomConnected(loadingRoomId, true);
this._flushEnqueuedRoomData(loadingRoomId);
break;
}
case 'leave-room-ok': {
const roomId = msg['room-id'];
this._trySetRoomConnected(roomId, false);
break;
}
case 'app-status-changed': {
const prevStatus = this._appStatusState.status;
this._setAppStatus(msg.status);
if (msg.status === 'disabled' && prevStatus !== 'disabled') {
// The server has stopped serving reads; surface the typed error on
// every registered query immediately instead of waiting for the
// next round trip.
const error = {
message: 'This app is currently disabled.',
hint: { status: 'disabled' },
};
Object.keys(this.queryCbs).forEach((hash) =>
this.notifyQueryError(hash, error),
);
}
if (prevStatus === 'disabled' && msg.status !== 'disabled') {
// Leaving `disabled`: refreshes were paused server-side, so restart
// the socket to re-add every query and get fresh results.
this._startSocket();
}
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:
this._log.info('Unknown op', msg.op, msg);
break;
}
}
createWriteStream(opts) {
return this._instantStream.createWriteStream(opts);
}
createReadStream(opts) {
return this._instantStream.createReadStream(opts);
}
_pendingMutations() {
return this.kv.currentValue.pendingMutations ?? new Map();
}
_updatePendingMutations(f) {
this.kv.updateInPlace((prev) => {
const muts = prev.pendingMutations ?? new Map();
prev.pendingMutations = muts;
f(muts);
});
}
/**
* @param {'timeout' | 'error'} status
* @param {string} eventId
* @param {{message?: string, type?: string, status?: number, hint?: unknown, traceId?: string}} errorMsg
*/
_handleMutationError(status, eventId, errorMsg) {
const mut = this._pendingMutations().get(eventId);
if (mut && (status !== 'timeout' || !mut['tx-id'])) {
this._updatePendingMutations((prev) => {
prev.delete(eventId);
return prev;
});
this._inFlightMutationEventIds.delete(eventId);
const errDetails = {
message: errorMsg.message,
hint: errorMsg.hint,
traceId: errorMsg.traceId,
};
this.notifyAll();
this.notifyAttrsSubs();
this.notifyMutationErrorSubs(errDetails);
this._finishTransaction(status, eventId, errorMsg);
}
}
_handleReceiveError(msg) {
this._log.info('error', msg);
const eventId = msg['client-event-id'];
// This might not be a mutation, but it can't hurt to delete it
this._inFlightMutationEventIds.delete(eventId);
const prevMutation = this._pendingMutations().get(eventId);
const errorMessage = {
message: msg.message || 'Uh-oh, something went wrong. Ping Joe & Stopa.',
};
if (msg.hint) {
errorMessage.hint = msg.hint;
}
if (msg['trace-id'] || msg['original-event']?.['trace-id']) {
const traceIds = [msg['trace-id'], msg['original-event']?.['trace-id']];
const traceId = traceIds.filter(Boolean).join(',');
errorMessage.traceId = traceId;
}
if (prevMutation) {
this._handleMutationError('error', eventId, errorMessage);
return;
}
if (
msg['original-event']?.hasOwnProperty('q') &&
msg['original-event']?.op === 'add-query'
) {
const q = msg['original-event']?.q;
const hash = weakHash(q);
this.notifyQueryError(weakHash(q), errorMessage);
this.notifyQueryOnceError(q, hash, eventId, errorMessage);
return;
}
const isInitError = msg['original-event']?.op === 'init';
if (isInitError) {
if (
msg.type === 'record-not-found' &&
msg.hint?.['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;
}
switch (msg['original-event']?.op) {
case 'resync-table': {
this._syncTable.onResyncError(msg);
return;
}
case 'start-sync': {
this._syncTable.onStartSyncError(msg);
return;
}
case 'start-stream':
case 'append-stream':
case 'subscribe-stream':
case 'unsubscribe-stream': {
this._instantStream.onRecieveError(msg);
return;
}
}
// We've caught some error which has no corresponding listener.
// Let's console.error to let the user know.
const errorObj = { ...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) {
const r = this.queryOnceDfds[hash]?.find((r) => r.eventId === eventId);
if (!r) return;
r.dfd.reject(e);
this._completeQueryOnce(q, hash, r.dfd);
}
_setAttrs(attrs) {
this.attrs = new s.AttrsStoreClass(
attrs.reduce((acc, attr) => {
acc[attr.id] = attr;
return acc;
}, {}),
this._linkIndex,
);
this.notifyAttrsSubs();
}
// ---------------------------
// Queries
getPreviousResult = (q) => {
const hash = weakHash(q);
return this.dataForQuery(hash)?.data;
};
_startQuerySub(q, hash) {
const eventId = uuid();
this.querySubs.updateInPlace((prev) => {
if (!prev[hash]) {
// Legacy hash migration (sync): if a preloaded entry sits under the
// old hash, move it to the new key so the placeholder line below
// becomes a no-op and currentValue[hash] keeps its cached result.
const legacyHash = weakHashLegacy(q);
if (legacyHash !== hash && prev[legacyHash]) {
prev[hash] = prev[legacyHash];
delete prev[legacyHash];
}
}
prev[hash] = prev[hash] || { q, result: null, eventId };
prev[hash].lastAccessed = Date.now();
});
this._tryMigrateLegacyQuerySub(q, hash);
this._trySendAuthed(eventId, { op: 'add-query', q });
return eventId;
}
// Legacy hash migration (async): covers entries that weren't preloaded
// into currentValue. Actively loads the legacy key from storage, moves
// its result onto the new key, and notifies subscribers. Safe to remove
// a few releases after v1.0.39.
async _tryMigrateLegacyQuerySub(q, newHash) {
const legacyHash = weakHashLegacy(q);
if (legacyHash === newHash) return;
await this.querySubs.waitForKeyToLoad(legacyHash);
const legacy = this.querySubs.currentValue[legacyHash];
if (!legacy) return;
let migratedResult = false;
this.querySubs.updateInPlace((prev) => {
const existing = prev[newHash];
if (existing && !existing.result && legacy.result) {
existing.result = legacy.result;
migratedResult = true;
} else if (!existing) {
prev[newHash] = legacy;
migratedResult = !!legacy.result;
}
delete prev[legacyHash];
});
if (migratedResult) this.notifyOne(newHash);
}
subscribeTable(q, cb) {
return this._syncTable.subscribe(q, cb);
}
/**
* 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) {
if (!this.config.disableValidation) {
validateQuery(q, this.config.schema);
}
if (opts && 'ruleParams' in opts) {
q = { $$ruleParams: opts['ruleParams'], ...q };
}
const hash = weakHash(q);
const prevResult = this.getPreviousResult(q);
if (prevResult) {
cb(prevResult);
}
this.queryCbs[hash] = this.queryCbs[hash] ?? [];
this.queryCbs[hash].push({ q, cb });
this._startQuerySub(q, hash);
return () => {
this._unsubQuery(q, hash, cb);
};
}
queryOnce(q, opts) {
if (!this.config.disableValidation) {
validateQuery(q, this.config.schema);
}
if (opts && 'ruleParams' in opts) {
q = { $$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] = this.queryOnceDfds[hash] ?? [];
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);
}
_hasQueryListeners(hash) {
return !!(this.queryCbs[hash]?.length || this.queryOnceDfds[hash]?.length);
}
_cleanupQuery(q, hash) {
const hasListeners = this._hasQueryListeners(hash);
if (hasListeners) return;
delete this.queryCbs[hash];
delete this.queryOnceDfds[hash];
delete this._dataForQueryCache[hash];
this.querySubs.unloadKey(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.
/**
*
* @param {s.AttrsStore} attrs
* @param {any} muts
* @param {number} [processedTxId]
*/
_rewriteMutations(attrs, muts, processedTxId) {
if (!attrs) return muts;
if (!muts) return new Map();
const findExistingAttr = (attr) => {
const [_, etype, label] = attr['forward-identity'];
const existing = s.getAttrByFwdIdentName(attrs, etype, label);
return existing;
};
const findReverseAttr = (attr) => {
const [_, etype, label] = attr['forward-identity'];
const revAttr = s.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, {
...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
/**
* @returns {s.AttrsStore}
*/
optimisticAttrs() {
const pendingMutationSteps = [...this._pendingMutations().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 &&
this.attrs?.getAttr(attr.id)
) {
const fullAttr = { ...this.attrs.getAttr(attr.id), ...attr };
pendingAttrs.push(fullAttr);
}
}
if (!deletedAttrIds.size && !pendingAttrs.length) {
return this.attrs || new s.AttrsStoreClass({}, this._linkIndex);
}
const attrs = { ...(this.attrs?.attrs || {}) };
for (const attr of pendingAttrs) {
attrs[attr.id] = attr;
}
for (const id of deletedAttrIds) {
delete attrs[id];
}
return new s.AttrsStoreClass(attrs, this._linkIndex);
}
/** Runs instaql on a query and a store */
dataForQuery(hash, applyOptimistic = true) {
const errorMessage = this._errorMessage;
if (errorMessage) {
return { error: errorMessage };
}
if (!this.querySubs) return;
if (!this.kv.currentValue.pendingMutations) return;
const querySubVersion = this.querySubs.version();
const querySubs = this.querySubs.currentValue;
const pendingMutationsVersion = this.kv.version();
const pendingMutations = this._pendingMutations();
const { q, result } = querySubs[hash] || {};
if (!result) return;
const cached = this._dataForQueryCache[hash];
if (
cached &&
querySubVersion === cached.querySubVersion &&
pendingMutationsVersion === cached.pendingMutationsVersion
) {
return cached;
}
let store = result.store;
let attrsStore = result.attrsStore;
const { pageInfo, aggregate, processedTxId } = result;
const mutations = this._rewriteMutationsSorted(
attrsStore,
pendingMutations,
);
if (applyOptimistic) {
const optimisticResult = this._applyOptimisticUpdates(
store,
attrsStore,
mutations,
processedTxId,
);
store = optimisticResult.store;
attrsStore = optimisticResult.attrsStore;
}
const resp = instaql(
{ store: store, attrsStore: attrsStore, pageInfo, aggregate },
q,
);
return { data: resp, querySubVersion, pendingMutationsVersion };
}
_applyOptimisticUpdates(store, attrsStore, mutations, processedTxId) {
for (const [_, mut] of mutations) {
if (!mut['tx-id'] || (processedTxId && mut['tx-id'] > processedTxId)) {
const result = s.transact(store, attrsStore, mut['tx-steps']);
store = result.store;
attrsStore = result.attrsStore;
}
}
return { store, attrsStore };
}
/** Re-run instaql and call all callbacks with new data */
notifyOne = (hash) => {
const cbs = this.queryCbs[hash] ?? [];
const prevData = this._dataForQueryCache[hash]?.data;
const resp = this.dataForQuery(hash);
if (!resp?.data) return;
this._dataForQueryCache[hash] = resp;
if (areObjectsDeepEqual(resp.data, prevData)) return;
cbs.forEach((r) => r.cb(resp.data));
};
notifyOneQueryOnce = (hash) => {
const dfds = this.queryOnceDfds[hash] ?? [];
const data = this.dataForQuery(hash)?.data;
dfds.forEach((r) => {
this._completeQueryOnce(r.q, hash, r.dfd);
r.dfd.resolve(data);
});
};
notifyQueryError = (hash, error) => {
// Clear the dedupe cache so the next successful result re-notifies
// subscribers even if the data is unchanged from before the error
delete this._dataForQueryCache[hash];
const cbs = this.queryCbs[hash] || [];
cbs.forEach((r) => r.cb({ error }));
};
/** Re-compute all subscriptions */
notifyAll() {
Object.keys(this.queryCbs).forEach((hash) => {
this.querySubs
.waitForKeyToLoad(hash)
.then(() => this.notifyOne(hash))
.catch(() => this.notifyOne(hash));
});
}
loadedNotifyAll() {
this.kv
.waitForKeyToLoad('pendingMutations')
.then(() => this.notifyAll())
.catch(() => this.notifyAll());
}
/** Applies transactions locally and sends transact message to server */
pushTx = (chunks) => {
// The server rejects every write while read-only or disabled, so reject
// locally instead of applying an optimistic update
if (this._appStatusState.isReadOnly) {
return Promise.reject(
this._appStatusState.status === 'disabled'
? new InstantError('This app is currently disabled.', {
status: 'disabled',
})
: new InstantError('This app is in read-only mode.', {
status: 'read-only',
}),
);
}
// Throws if transactions are invalid
if (!this.config.disableValidation) {
validateTransactions(chunks, this.config.schema);
}
try {
const txSteps = instaml.transform(
{
attrsStore: this.optimisticAttrs(),
schema: this.config.schema,
stores: Object.values(this.querySubs.currentValue).map(
(sub) => sub?.result?.store,
),
useDateObjects: this.config.useDateObjects,
},
chunks,
);
return this.pushOps(txSteps);
} catch (e) {
return this.pushOps([], e);
}
};
/**
* @param {*} txSteps
* @param {*} [error]
* @returns
*/
pushOps = (txSteps, error) => {
const eventId = uuid();
const mutations = [...this._pendingMutations().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._updatePendingMutations((prev) => {
prev.set(eventId, mutation);
});
const dfd = new Deferred();
this.mutationDeferredStore.set(eventId, dfd);
this._sendMutation(eventId, mutation);
this.notifyAll();
return dfd.promise;
};
shutdown() {
this._log.info('[shutdown]', this.config.appId);
this._isShutdown = true;
this._transport?.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, {
message: mutation.error.message,
});
return;
}
if (this.status !== STATUS.AUTHENTICATED) {
this._finishTransaction('enqueued', eventId);
return;
}
const timeoutMs = Math.max(
6000,
Math.min(
this._inFlightMutationEventIds.size + 1,
// Defensive code in case we don't clean up in flight mutation event ids
this._pendingMutations().size + 1,
) * 6000,
);
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 rewrittenMutations = this._rewriteMutations(
this.ensureAttrs(),
this._pendingMutations(),
);
if (rewrittenMutations !== this._pendingMutations()) {
// Persist rewritten mutations to avoid stale attr ids in future txs.
this.kv.updateInPlace((prev) => {
prev.pendingMutations = rewrittenMutations;
});
}
const muts = sortedMutationEntries(rewrittenMutations.entries());
muts.forEach(([eventId, mut]) => {
if (!mut['tx-id']) {
this._sendMutation(eventId, mut);
}
});
this._syncTable.flushPending();
}
/**
* 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?.processedTxId) {
minProcessedTxId = Math.min(minProcessedTxId, result?.processedTxId);
}
}
this._updatePendingMutations((prev) => {
for (const [eventId, mut] of Array.from(prev.entries())) {
if (mut['tx-id'] && mut['tx-id'] <= minProcessedTxId) {
prev.delete(eventId);
}
}
});
}
/**
* 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() {
if (this._pendingMutations().size < this._pendingMutationCleanupThreshold) {
return;
}
const now = Date.now();
this._updatePendingMutations((prev) => {
for (const [eventId, mut] of Array.from(prev.entries())) {
if (
mut.confirmed &&
mut.confirmed + this._pendingTxCleanupTimeout < now
) {
prev.delete(eventId);
}
}
});
}
_trySendAuthed(...args) {
if (this.status !== STATUS.AUTHENTICATED) {
return;
}
this._trySend(...args);
}
_trySend(eventId, msg, opts) {
if (!this._transport.isOpen()) {
return;
}
if (!ignoreLogging[msg.op]) {
this._log.info('[send]', this._transport.id, msg.op, {
'client-event-id': eventId,
...msg,
});
}
switch (msg.op) {
case 'transact': {
this._inFlightMutationEventIds.add(eventId);
break;
}
case 'init': {
// New connection, so we can't have any mutations in flight
this._inFlightMutationEventIds.clear();
}
}
this._transport.send({ 'client-event-id': eventId, ...msg });
}
_transportOnOpen = (e) => {
const targetTransport = e.target;
if (this._transport !== targetTransport) {
this._log.info(
'[socket][open]',
targetTransport.id,
'skip; this is no longer the current transport',
);
return;
}
this._log.info('[socket][open]', this._transport.id);
this._setStatus(STATUS.OPENED);
this.getCurrentUser()
.then((resp) => {
this._trySend(uuid(), {
op: 'init',
'app-id': this.config.appId,
'refresh-token': resp.user?.['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]', targetTransport.id, e);
});
};
_transportOnMessage = (e) => {
const targetTransport = e.target;
const m = e.message;
if (this._transport !== targetTransport) {
this._log.info(
'[socket][message]',
targetTransport.id,
m,
'skip; this is no longer the current transport',
);
return;
}
if (!this._wsOk && targetTransport.type === 'ws') {
this._wsOk = true;
}
// Try to reconnect via websocket the next time we connect
this._transportType = 'ws';
if (Array.isArray(e.message)) {
for (const msg of e.message) {
this._handleReceive(targetTransport.id, msg);
}
} else {
this._handleReceive(targetTransport.id, e.message);
}
};
_transportOnError = (e) => {
const targetTransport = e.target;
if (this._transport !== targetTransport) {
this._log.info(
'[socket][error]',
targetTransport.id,
'skip; this is no longer the current transport',
);
return;
}
this._log.error('[socket][error]', targetTransport.id, e);
};
_scheduleReconnect = () => {
// If we couldn't connect with a websocket last time, try sse
if (!this._wsOk && this._transportType !== 'sse') {
this._transportType = 'sse';
this._reconnectTimeoutMs = 0;
}
setTimeout(() => {
this._reconnectTimeoutMs = Math.min(
this._reconnectTimeoutMs + 1000,
10000,
);
if (!this._isOnline) {
this._log.info(
'[socket][close]',
this._transport.id,
'we are offline, no need to start socket',
);
return;
}
this._startSocket();
}, this._reconnectTimeoutMs);
};
_transportOnClose = (e) => {
const targetTransport = e.target;
if (this._transport !== targetTransport) {
this._log.info(
'[socket][close]',
targetTransport.id,
'skip; this is no longer the current transport',
);
return;
}
this._setStatus(STATUS.CLOSED);
for (const room of Object.values(this._rooms)) {
room.isConnected = false;
}
if (this._isShutdown) {
this._log.info(
'[socket][close]',
targetTransport.id,
'Reactor has been shut down and will not reconnect',
);
return;
}
this._log.info(
'[socket][close]',
targetTransport.id,
'schedule reconnect, ms =',
this._reconnectTimeoutMs,
);
this._scheduleReconnect();
};
_startSocket() {
// Reset whether we support websockets each time we connect
// new networks may not support websockets
this._wsOk = null;
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._transport && this._transport.isConnecting()) {
// 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._transport.id,
'maintained as current transport, we were still in a connecting state',
);
return;
}
const prevTransport = this._transport;
this._transport = createTransport({
transportType: this._transportType,
appId: this.config.appId,
apiURI: this.config.apiURI,
wsURI: this.config.websocketURI,
EventSourceImpl: this._EventSource,
});
this._transport.onopen = this._transportOnOpen;
this._transport.onmessage = this._transportOnMessage;
this._transport.onclose = this._transportOnClose;
this._transport.onerror = this._transportOnError;
this._log.info('[socket][start]', this._transport.id);
if (prevTransport?.isOpen()) {
// 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._transport.id,
'close previous transport id = ',
prevTransport.id,
);
prevTransport.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.
*
*/
async getLocalId(name) {
const k = `localToken_${name}`;
if (this.kv.currentValue[k]) {
return this.kv.currentValue[k];
}
const current = await this.kv.waitForKeyToLoad(k);
if (current) {
return current;
}
const newId = uuid();
this.kv.updateInPlace((prev) => {
if (prev[k]) return;
prev[k] = newId;
});
return await this.kv.waitForKeyToLoad(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(OAUTH_EXTRA_FIELDS_ID_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);
// navigation is part of the HTML spec, but not supported by Safari
// or Firefox yet:
// https://developer.mozilla.org/en-US/docs/Web/API/Navigation_API#browser_compatibility
if (
// @ts-ignore (waiting for ts support)
typeof navigation === 'object' &&
// @ts-ignore (waiting for ts support)
typeof navigation.addEventListener === 'function' &&
// @ts-ignore (waiting for ts support)
typeof navigation.removeEventListener === 'function'
) {
let ran = false;
// The next.js app router will reset the URL when the router loads.
// This puts it back after the router loads.
const listener = (e) => {
if (!ran) {
ran = true;
// @ts-ignore (waiting for ts support)
navigation.removeEventListener('navigate', listener);
if (
!e.userInitiated &&
e.navigationType === 'replace' &&
e.destination?.url === startUrl
) {
history.replaceState(history.state, '', newPath);
}
}
};
// @ts-ignore (waiting for ts support)
navigation.addEventListener('navigate', listener);
}
}
}
/**
*
* @returns Promise<null | {error: {message: string}}>
*/
async _oauthLoginInit() {
if (
typeof window === 'undefined' ||
typeof window.location === 'undefined' ||
typeof URLSearchParams === 'undefined'
) {
return null;
}
const params = new URLSearchParams(window.location.search);
if (!params.get(OAUTH_REDIRECT_PARAM)) {
return null;
}
const error = params.get('error');
if (error) {
this._replaceUrlAfterOAuth();
return { error: { message: error } };
}
const code = params.get('code');
if (!code) {
return null;
}
const extraFieldsId = params.get(OAUTH_EXTRA_FIELDS_ID_PARAM);
this._replaceUrlAfterOAuth();
try {
let extraFields;
const stored = await this.kv.waitForKeyToLoad(oauthExtraFieldsKey);
if (extraFieldsId && stored) {
extraFields = stored[extraFieldsId];
}
// Clean up all stored extraFields after login
if (stored) {
this.kv.updateInPlace((prev) => {
delete prev[oauthExtraFieldsKey];
});
}
const currentUser = await this._getCurrentUser();
const isGuest = currentUser?.type === 'guest';
const { user } = await authAPI.exchangeCodeForToken({
apiURI: this.config.apiURI,
appId: this.config.appId,
code,
refreshToken: isGuest ? currentUser.refresh_token : undefined,
extraFields,
});
this.setCurrentUser(user);
return null;
} catch (e) {
if (
e?.body?.type === 'record-not-found' &&
e?.body?.hint?.['record-type'] === 'app-oauth-code' &&
(await this._hasCurrentUser())
) {
// We probably just weren't able to clean up the URL, so
// let's just ignore this error
return null;
}
const message = e?.body?.message || 'Error logging in.';
return { error: { message } };
}
}
async _waitForOAuthCallbackResponse() {
return await this._oauthCallbackResponse;
}
__subscribeMutationErrors(cb) {
this.mutationErrorCbs.push(cb);
return () => {
this.mutationErrorCbs = this.mutationErrorCbs.filter((x) => x !== cb);
};
}
subscribeAuth(cb) {
this.authCbs.push(cb);
const currUserCached = this._currentUserCached;
if (!currUserCached.isLoading) {
cb(this._currentUserCached);
}
let unsubbed = false;
this.getCurrentUser().then((resp) => {
if (unsubbed) return;
if (areObjectsDeepEqual(resp, currUserCached)) return;
cb(resp);
});
return () => {
unsubbed = true;
this.authCbs = this.authCbs.filter((x) => x !== cb);
};
}
async getAuth() {
const { user, error } = await this.getCurrentUser();
if (error) {
throw new InstantError('Could not get current user: ' + error.message);
}
return user ?? null;
}
subscribeConnectionStatus(cb) {
this.connectionStatusCbs.push(cb);
return () => {
this.connectionStatusCbs = this.connectionStatusCbs.filter(
(x) => x !== cb,
);
};
}
/**
* @param {'active' | 'read-only' | 'disabled' | undefined} status
*/
_setAppStatus(status) {
if (!status || status === this._appStatusState.status) return;
this._appStatusState = {
isLoading: false,
isReadOnly: status !== 'active',
status,
};
this.notifyAppStatusSubs(this._appStatusState);
}
subscribeAppStatus(cb) {
this.appStatusCbs.push(cb);
cb(this._appStatusState);
return () => {
this.appStatusCbs = this.appStatusCbs.filter((x) => x !== cb);
};
}
subscribeAttrs(cb) {
this.attrsCbs.push(cb);
if (this.attrs) {
cb(this.attrs.attrs);
}
return () => {
this.attrsCbs = this.attrsCbs.filter((x) => x !== cb);
};
}
notifyAuthSubs(user) {
this.authCbs.forEach((cb) => cb(user));
}
notifyMutationErrorSubs(error) {
this.mutationErrorCbs.forEach((cb) => cb(error));
}
notifyAttrsSubs() {
if (!this.attrs) return;
const oas = this.optimisticAttrs();
this.attrsCbs.forEach((cb) => cb(oas.attrs));
}
notifyConnectionStatusSubs(status) {
this.connectionStatusCbs.forEach((cb) => cb(status));
}
notifyAppStatusSubs(state) {
this.appStatusCbs.forEach((cb) => cb(state));
}
async setCurrentUser(user) {
this.kv.updateInPlace((prev) => {
prev[currentUserKey] = user;
});
await this.kv.waitForKeyToLoad(currentUserKey);
}
getCurrentUserCached() {
return this._currentUserCached;
}
/**
* @param {{ forceReadFromStorage?: boolean }} [opts]
* @returns {Promise<User | undefined>}
*/
async _getCurrentUser(opts) {
if (opts?.forceReadFromStorage) {
await this.kv.unloadKey(currentUserKey);
}
const user = await this.kv.waitForKeyToLoad(currentUserKey);
return typeof user === 'string' ? JSON.parse(user) : user;
}
/**
* @param {{ forceReadFromStorage?: boolean }} [opts]
* @returns {Promise<AuthState>}
*/
async getCurrentUser(opts) {
const oauthResp = await this._waitForOAuthCallbackResponse();
if (oauthResp?.error) {
const errorV = { error: oauthResp.error, user: undefined };
this._currentUserCached = { isLoading: false, ...errorV };
return this._currentUserCached;
}
try {
const user = await this._getCurrentUser(opts);
const userV = { user: user, error: undefined };
this._currentUserCached = {
isLoading: false,
...userV,
};
return this._currentUserCached;
} catch (e) {
return {
user: undefined,
isLoading: false,
error: { message: e?.message || 'Error loading user' },
};
}
}
async _hasCurrentUser() {
const user = await this.kv.waitForKeyToLoad(currentUserKey);
return typeof user === 'string' ? JSON.parse(user) != null : user != null;
}
async changeCurrentUser(newUser) {
const { user: oldUser } = await this.getCurrentUser();
if (areObjectsDeepEqual(oldUser, newUser)) {
// We were already logged in as the newUser, don't
// bother updating
return;
}
// Clear any data in the framework client
this._frameworkClient = null;
await this.setCurrentUser(newUser);
// We need to remove all `result` from querySubs,
// as they are no longer valid for the new user
await this.updateUser(newUser);
try {
// Make sure everything is written to storage before we tell the
// other tab to refetch
await this.kv.flush();
this._broadcastChannel?.postMessage({ type: 'auth' });
} catch (error) {
console.error('Error posting message to broadcast channel', error);
}
}
async setupUserSyncTimer() {
if (!this.config.firstPartyPath) return;
try {
const lastSynced = await this.kv.waitForKeyToLoad(
COOKIE_SYNC_LAST_UPDATED_KEY,
);
const lastSyncTime = lastSynced ? new Date(lastSynced).getTime() : 0;
const shouldSync =
!Number.isFinite(lastSyncTime) ||
Date.now() - lastSyncTime >= ONE_DAY_MS;
if (!shouldSync) return;
const { user } = await this.getCurrentUser();
await this.syncUserToEndpoint(user ?? null);
} catch (error) {
this._log.error('Error syncing user cookie on startup', error);
}
}
async syncUserToEndpoint(user) {
if (!this.config.firstPartyPath) return;
try {
const response = await fetch(this.config.firstPartyPath + '/', {
method: 'POST',
body: JSON.stringify({
type: 'sync-user',
appId: this.config.appId,
user: user,
}),
headers: {
'Content-Type': 'application/json',
},
});
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
} catch (error) {
this._log.error('Error syncing user with external endpoint', error);
}
this.kv.updateInPlace((prev) => {
prev[COOKIE_SYNC_LAST_UPDATED_KEY] = new Date().toISOString();
});
}
async updateUser(newUser) {
const newV = { error: undefined, user: newUser };
this._currentUserCached = { isLoading: false, ...newV };
this._dataForQueryCache = {};
this.querySubs.updateInPlace((prev) => {
Object.keys(prev).forEach((k) => {
delete prev[k].result;
});
});
this.querySubs.clearUnloadedKeys();
this._updatePendingMutations((prev) => {
// Mark all pending mutations with an error, since we won't be able to
// deliver the result
for (const [eventId, _v] of prev.entries()) {
if (this.mutationDeferredStore.get(eventId)) {
this._finishTransaction('error', eventId, {
message: 'User changed while transaction was in progress.',
type: 'user-changed',
});
}
}
prev.clear();
});
this._reconnectTimeoutMs = 0;
this._transport.close();
this._oauthCallbackResponse = null;
this.notifyAuthSubs(newV);
try {
await this.syncUserToEndpoint(newUser);
} catch (error) {
this._log.error('Error syncing user with external endpoint', error);
}
}
sendMagicCode({ email }) {
return authAPI.sendMagicCode({
apiURI: this.config.apiURI,
appId: this.config.appId,
email: email,
});
}
async signInWithMagicCode(params) {
const currentUser = await this.getCurrentUser();
const isGuest = currentUser?.user?.type === 'guest';
const res = await authAPI.checkMagicCode({
apiURI: this.config.apiURI,
appId: this.config.appId,
email: params.email,
code: params.code,
refreshToken: isGuest ? currentUser?.user?.refresh_token : undefined,
extraFields: params.extraFields,
});
await this.changeCurrentUser(res.user);
return res;
}
async signInWithCustomToken(authToken) {
const res = await authAPI.verifyRefreshToken({
apiURI: this.config.apiURI,
appId: this.config.appId,
refreshToken: authToken,
});
await this.changeCurrentUser(res.user);
return res;
}
async signInAsGuest(params) {
const res = await authAPI.signInAsGuest({
apiURI: this.config.apiURI,
appId: this.config.appId,
extraFields: params?.extraFields,
});
await this.changeCurrentUser(res.user);
return res;
}
potentiallyInvalidateToken(currentUser, opts) {
const refreshToken = currentUser?.user?.refresh_token;
if (!refreshToken) {
return;
}
const wantsToSkip = opts.invalidateToken === false;
if (wantsToSkip) {
this._log.info('[auth-invalidate] skipped invalidateToken');
return;
}
authAPI
.signOut({
apiURI: this.config.apiURI,
appId: this.config.appId,
refreshToken,
})
.then(() => {
this._log.info('[auth-invalidate] completed invalidateToken');
})
.catch((e) => {});
}
async signOut(opts) {
const currentUser = await this.getCurrentUser();
this.potentiallyInvalidateToken(currentUser, opts);
await this.changeCurrentUser(null);
}
/**
* Creates an OAuth authorization URL.
*
* @param {Object} params - The parameters to create the authorization URL.
* @param {string} params.clientName - The name of the client requesting authorization.
* @param {string} params.redirectURL - The URL to redirect users to after authorization.
* @param {Record<string, any>} [params.extraFields] - Extra fields to write to $users on creation
* @returns {string} The created authorization URL.
*/
createAuthorizationURL({ clientName, redirectURL, extraFields }) {
const { apiURI, appId } = this.config;
let finalRedirectURL = redirectURL;
if (extraFields) {
// Store extraFields under a unique ID so multiple
// createAuthorizationURL calls don't overwrite each other.
// The ID is passed through the redirect URL and used
// by _oauthLoginInit to retrieve the right extraFields.
// All entries are cleaned up after login.
const extraFieldsId = `${Math.random().toString(36).slice(2)}`;
this.kv.updateInPlace((prev) => {
const stored = prev[oauthExtraFieldsKey] || {};
stored[extraFieldsId] = extraFields;
prev[oauthExtraFieldsKey] = stored;
});
finalRedirectURL = `${redirectURL}${redirectURL.includes('?') ? '&' : '?'}${OAUTH_EXTRA_FIELDS_ID_PARAM}=${extraFieldsId}`;
}
return `${apiURI}/runtime/oauth/start?app_id=${appId}&client_name=${clientName}&redirect_uri=${encodeURIComponent(finalRedirectURL)}`;
}
/**
* @param {Object} params
* @param {string} params.code - The code received from the OAuth service.
* @param {string} [params.codeVerifier] - The code verifier used to generate the code challenge.
* @param {Record<string, any>} [params.extraFields] - Extra fields to write to $users on creation
*/
async exchangeCodeForToken({ code, codeVerifier, extraFields }) {
const currentUser = await this.getCurrentUser();
const isGuest = currentUser?.user?.type === 'guest';
const res = await authAPI.exchangeCodeForToken({
apiURI: this.config.apiURI,
appId: this.config.appId,
code: code,
codeVerifier,
refreshToken: isGuest ? currentUser?.user?.refresh_token : undefined,
extraFields,
});
await this.changeCurrentUser(res.user);
return res;
}
issuerURI() {
const { apiURI, appId } = this.config;
return `${apiURI}/runtime/${appId}`;
}
/**
* @param {Object} params
* @param {string} params.clientName - The name of the client requesting authorization.
* @param {string} params.idToken - The id_token from the external service
* @param {string | null | undefined} [params.nonce] - The nonce used when requesting the id_token from the external service
* @param {Record<string, any>} [params.extraFields] - Extra fields to write to $users on creation
*/
async signInWithIdToken(params) {
const currentUser = await this.getCurrentUser();
const refreshToken = currentUser?.user?.refresh_token;
const res = await authAPI.signInWithIdToken({
apiURI: this.config.apiURI,
appId: this.config.appId,
idToken: params.idToken,
clientName: params.clientName,
nonce: params.nonce,
refreshToken,
extraFields: params.extraFields,
});
await this.changeCurrentUser(res.user);
return res;
}
// --------
// Rooms
/**
* @param {string} roomType
* @param {string} roomId
* @param {any | null | undefined} [initialPresence] -- initial presence data to send when joining the room
* @returns () => void
*/
joinRoom(roomType, roomId, initialPresence) {
let needsToSendJoin = false;
if (!this._rooms[roomId]) {
needsToSendJoin = true;
this._rooms[roomId] = {
roomType,
isConnected: false,
error: undefined,
};
}
this._presence[roomId] = this._presence[roomId] || {};
const previousResult = this._presence[roomId].result;
if (initialPresence && !previousResult) {
this._presence[roomId].result = this._presence[roomId].result || {};
this._presence[roomId].result.user = initialPresence;
this._notifyPresenceSubs(roomId);
}
if (needsToSendJoin) {
this._tryJoinRoom(roomType, roomId, initialPresence);
}
return () => {
this._cleanupRoom(roomId);
};
}
_cleanupRoom(roomId) {
if (
!this._presence[roomId]?.handlers?.length &&
!Object.keys(this._broadcastSubs[roomId] ?? {}).length
) {
const isConnected = this._rooms[roomId]?.isConnected;
delete this._rooms[roomId];
delete this._presence[roomId];
delete this._broadcastSubs[roomId];
if (isConnected) {
this._tryLeaveRoom(roomId);
} else {
this._roomsPendingLeave[roomId] = true;
}
}
}
// --------
// Presence
// TODO: look into typing again
getPresence(roomType, roomId, opts = {}) {
const room = this._rooms[roomId];
const presence = this._presence[roomId];
if (!room || !presence || !presence.result) return null;
return {
...buildPresenceSlice(presence.result, opts, this._sessionId),
isLoading: !room.isConnected,
error: room.error,
};
}
// TODO: look into typing again
publishPresence(roomType, roomId, partialData) {
const room = this._rooms[roomId];
const presence = this._presence[roomId];
if (!room || !presence) {
return;
}
presence.result = presence.result || {};
const data = {
...presence.result.user,
...partialData,
};
presence.result.user = data;
if (!room.isConnected) {
return;
}
this._trySetPresence(roomId, data);
this._notifyPresenceSubs(roomId);
}
_trySetPresence(roomId, data) {
this._trySendAuthed(uuid(), {
op: 'set-presence',
'room-id': roomId,
data,
});
}
_tryJoinRoom(roomType, roomId, data) {
this._trySendAuthed(uuid(), {
op: 'join-room',
'room-type': roomType,
'room-id': roomId,
data,
});
delete this._roomsPendingLeave[roomId];
}
_tryLeaveRoom(roomId) {
this._trySendAuthed(uuid(), { op: 'leave-room', 'room-id': roomId });
}
_trySetRoomConnected(roomId, isConnected) {
const room = this._rooms[roomId];
if (room) {
room.isConnected = isConnected;
}
}
// TODO: look into typing again
subscribePresence(roomType, roomId, opts, cb) {
const leaveRoom = this.joinRoom(
roomType,
roomId,
// Oct 28, 2025
// Note: initialData is deprecated.
// Keeping here for backwards compatibility
opts.initialPresence || opts.initialData,
);
const handler = { ...opts, roomId, cb, prev: null };
this._presence[roomId] = this._presence[roomId] || {};
this._presence[roomId].handlers = this._presence[roomId].handlers || [];
this._presence[roomId].handlers.push(handler);
this._notifyPresenceSub(roomId, handler);
return () => {
this._presence[roomId].handlers =
this._presence[roomId]?.handlers?.filter((x) => x !== handler) ?? [];
leaveRoom();
};
}
_notifyPresenceSubs(roomId) {
this._presence[roomId]?.handlers?.forEach((handler) => {
this._notifyPresenceSub(roomId, handler);
});
}
_notifyPresenceSub(roomId, handler) {
const slice = this.getPresence('', roomId, handler);
if (!slice) {
return;
}
if (handler.prev && !hasPresenceResponseChanged(slice, handler.prev)) {
return;
}
handler.prev = slice;
handler.cb(slice);
}
_patchPresencePeers(roomId, edits) {
const peers = this._presence[roomId]?.result?.peers || {};
let sessions = Object.fromEntries(
Object.entries(peers).map(([k, v]) => [k, { data: v }]),
);
const myPresence = this._presence[roomId]?.result;
const newSessions = create(sessions, (draft) => {
for (let [path, op, value] of edits) {
switch (op) {
case '+':
insertInMutative(draft, path, value);
break;
case 'r':
assocInMutative(draft, path, value);
break;
case '-':
dissocInMutative(draft, path);
break;
}
}
// Ignore our own edits
delete draft[this._sessionId];
});
this._setPresencePeers(roomId, newSessions);
}
_setPresencePeers(roomId, data) {
const sessions = { ...data };
// no need to keep track of `user`
delete sessions[this._sessionId];
const peers = Object.fromEntries(
Object.entries(sessions).map(([k, v]) => [k, v.data]),
);
this._presence = create(this._presence, (draft) => {
assocInMutative(draft, [roomId, 'result', 'peers'], peers);
});
}
// --------
// Broadcast
publishTopic({ roomType, roomId, topic, data }) {
const room = this._rooms[roomId];
if (!room) {
return;
}
if (!room.isConnected) {
this._broadcastQueue[roomId] = this._broadcastQueue[roomId] ?? [];
this._broadcastQueue[roomId].push({ topic, roomType, data });
return;
}
this._tryBroadcast(roomId, roomType, topic, data);
}
_tryBroadcast(roomId, roomType, topic, data) {
this._trySendAuthed(uuid(), {
op: 'client-broadcast',
'room-id': roomId,
roomType,
topic,
data,
});
}
subscribeTopic(roomType, roomId, topic, cb) {
const leaveRoom = this.joinRoom(roomType, roomId);
this._broadcastSubs[roomId] = this._broadcastSubs[roomId] || {};
this._broadcastSubs[roomId][topic] =
this._broadcastSubs[roomId][topic] || [];
this._broadcastSubs[roomId][topic].push(cb);
this._presence[roomId] = this._presence[roomId] || {};
return () => {
this._broadcastSubs[roomId][topic] = this._broadcastSubs[roomId][
topic
].filter((x) => x !== cb);
if (!this._broadcastSubs[roomId][topic].length) {
delete this._broadcastSubs[roomId][topic];
}
leaveRoom();
};
}
_notifyBroadcastSubs(room, topic, msg) {
this._broadcastSubs?.[room]?.[topic]?.forEach((cb) => {
const data = msg.data?.data;
const peer =
msg.data['peer-id'] === this._sessionId
? this._presence[room]?.result?.user
: this._presence[room]?.result?.peers?.[msg.data['peer-id']];
return cb(data, peer);
});
}
// --------
// Storage
async uploadFile(path, file, opts) {
const currentUser = await this.getCurrentUser();
const refreshToken = currentUser?.user?.refresh_token;
return StorageApi.uploadFile({
...opts,
apiURI: this.config.apiURI,
appId: this.config.appId,
path: path,
file,
refreshToken: refreshToken,
});
}
async deleteFile(path) {
const currentUser = await this.getCurrentUser();
const refreshToken = currentUser?.user?.refresh_token;
const result = await StorageApi.deleteFile({
apiURI: this.config.apiURI,
appId: this.config.appId,
path,
refreshToken: refreshToken,
});
return result;
}
// Deprecated Storage API (Jan 2025)
// ---------------------------------
async upload(path, file) {
const currentUser = await this.getCurrentUser();
const refreshToken = currentUser?.user?.refresh_token;
const fileName = path || file.name;
const url = await StorageApi.getSignedUploadUrl({
apiURI: this.config.apiURI,
appId: this.config.appId,
fileName: fileName,
refreshToken: refreshToken,
});
const isSuccess = await StorageApi.upload(url, file);
return isSuccess;
}
async getDownloadUrl(path) {
const currentUser = await this.getCurrentUser();
const refreshToken = currentUser?.user?.refresh_token;
const url = await StorageApi.getDownloadUrl({
apiURI: this.config.apiURI,
appId: this.config.appId,
path: path,
refreshToken: refreshToken,
});
return url;
}
}