UNPKG

@instantdb/core

Version:
2,391 lines • 95.1 kB
"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 __importDefault = (this && this.__importDefault) || function (mod) {
    return (mod && mod.__esModule) ? mod : { "default": mod };
};
Object.defineProperty(exports, "__esModule", { value: true });
exports.COOKIE_SYNC_LAST_UPDATED_KEY = exports.STATUS = void 0;
// @ts-check
const weakHash_ts_1 = __importDefault(require("./utils/weakHash.js"));
const weakHashLegacy_ts_1 = __importDefault(require("./utils/weakHashLegacy.js"));
const instaql_ts_1 = __importDefault(require("./instaql.js"));
const instaml = __importStar(require("./instaml.js"));
const s = __importStar(require("./store.js"));
const id_ts_1 = __importDefault(require("./utils/id.js"));
const IndexedDBStorage_ts_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_ts_1 = require("./utils/Deferred.js");
const PersistedObject_ts_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_ts_1 = __importDefault(require("./version.js"));
const mutative_1 = require("mutative");
const log_ts_1 = __importDefault(require("./utils/log.js"));
const queryValidation_ts_1 = require("./queryValidation.js");
const transactionValidation_ts_1 = require("./transactionValidation.js");
const InstantError_ts_1 = require("./InstantError.js");
const fetch_ts_1 = require("./utils/fetch.js");
const uuid_1 = require("uuid");
const Connection_ts_1 = require("./Connection.js");
const SyncTable_ts_1 = require("./SyncTable.js");
const Stream_ts_1 = require("./Stream.js");
/** @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 */
exports.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;
exports.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 Connection_ts_1.WSConnection(`${wsURI}?app_id=${appId}`);
    }
    switch (transportType) {
        case 'ws':
            return new Connection_ts_1.WSConnection(`${wsURI}?app_id=${appId}`);
        case 'sse':
            return new Connection_ts_1.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 = {}]
 */
class Reactor {
    /** @type {s.AttrsStore | undefined} */
    attrs;
    _isOnline = true;
    _isShutdown = false;
    status = exports.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_ts_1.default, NetworkListener = WindowNetworkListener_js_1.default, 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 = (0, log_ts_1.default)(config.verbose || flags.devBackend || flags.instantLogs, () => this._reactorStats(), config.logger);
        this.versions = { ...(versions || {}), '@instantdb/core': version_ts_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 (!config.appId) {
            throw new Error('Instant must be initialized with an appId.');
        }
        if (!(0, uuid_1.validate)(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_ts_1.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 Stream_ts_1.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', exports.STATUS.CLOSED);
                    this._setStatus(exports.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 ? (0, linkIndex_ts_1.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_ts_1.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_ts_1.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 fetch_ts_1.InstantAPIError({
                    // @ts-expect-error body.type is not constant typed
                    body,
                    status: status ?? 0,
                }));
            }
            else {
                dfd.reject(new InstantError_ts_1.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 = (0, weakHash_ts_1.default)(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(exports.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((0, weakHash_ts_1.default)(msg.q));
                break;
            }
            case 'add-query-ok': {
                const { q, result } = msg;
                const hash = (0, weakHash_ts_1.default)(q);
                if (!this._hasQueryListeners() && !this.querySubs.currentValue[hash]) {
                    break;
                }
                const pageInfo = result?.[0]?.data?.['page-info'];
                const aggregate = result?.[0]?.data?.['aggregate'];
                const triples = (0, instaqlResult_js_1.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 = (0, weakHash_ts_1.default)(q);
                    const triples = (0, instaqlResult_js_1.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 = (0, weakHash_ts_1.default)(q);
            this.notifyQueryError((0, weakHash_ts_1.default)(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(exports.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 = (0, weakHash_ts_1.default)(q);
        return this.dataForQuery(hash)?.data;
    };
    _startQuerySub(q, hash) {
        const eventId = (0, id_ts_1.default)();
        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 = (0, weakHashLegacy_ts_1.default)(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 = (0, weakHashLegacy_ts_1.default)(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) {
            (0, queryValidation_ts_1.validateQuery)(q, this.config.schema);
        }
        if (opts && 'ruleParams' in opts) {
            q = { $$ruleParams: opts['ruleParams'], ...q };
        }
        const hash = (0, weakHash_ts_1.default)(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) {
            (0, queryValidation_ts_1.validateQuery)(q, this.config.schema);
        }
        if (opts && 'ruleParams' in opts) {
            q = { $$ruleParams: opts['ruleParams'], ...q };
        }
        const dfd = new Deferred_ts_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] = 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((0, id_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.
    /**
     *
     * @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 = (0, instaql_ts_1.default)({ 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 ((0, object_js_1.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_ts_1.InstantError('This app is currently disabled.', {
                    status: 'disabled',
                })
                : new InstantError_ts_1.InstantError('This app is in read-only mode.', {
                    status: 'read-only',
                }));
        }
        // Throws if transactions are invalid
        if (!this.config.disableValidation) {
            (0, transactionValidation_ts_1.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 = (0, id_ts_1.default)();
        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_ts_1.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 !== exports.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 !== exports.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(exports.STATUS.OPENED);
        this.getCurrentUser()
            .then((resp) => {
            this._trySend((0, id_ts_1.default)(), {
                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(exports.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 = (0, id_ts_1.default)();
        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 ((0, object_js_1.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_ts_1.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 ((0, object_js_1.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(exports.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[exports.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 {
            ...(0, presence_ts_1.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((0, id_ts_1.default)(), {
            op: 'set-presence',
            'room-id': roomId,
            data,
        });
    }
    _tryJoinRoom(roomType, roomId, data) {
        this._trySendAuthed((0, id_ts_1.default)(), {
            op: 'join-room',
            'room-type': roomType,
            'room-id': roomId,
            data,
        });
        delete this._roomsPendingLeave[roomId];
    }
    _tryLeaveRoom(roomId) {
        this._trySendAuthed((0, id_ts_1.default)(), { 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 && !(0, presence_ts_1.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 = (0, mutative_1.create)(sessions, (draft) => {
            for (let [path, op, value] of edits) {
                switch (op) {
                    case '+':
                        (0, object_js_1.insertInMutative)(draft, path, value);
                        break;
                    case 'r':
                        (0, object_js_1.assocInMutative)(draft, path, value);
                        break;
                    case '-':
                        (0, object_js_1.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 = (0, mutative_1.create)(this._presence, (draft) => {
            (0, object_js_1.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((0, id_ts_1.default)(), {
            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;
    }
}
exports.default = Reactor;
//# sourceMappingURL=Reactor.js.map