UNPKG

@tanstack/db

Version:

A reactive client store for building super fast apps on sync

1,177 lines (1,176 loc) 43.7 kB
"use strict"; Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" }); const autoIndex = require("../indexes/auto-index.cjs"); const functions = require("../query/builder/functions.cjs"); const ir = require("../query/ir.cjs"); const eventEmitter = require("../event-emitter.cjs"); const evaluators = require("../query/compiler/evaluators.cjs"); const cursor = require("../utils/cursor.cjs"); const utils = require("../utils.cjs"); const error = require("../utils/error.cjs"); const callbacks = require("../utils/callbacks.cjs"); const deferred = require("../deferred.cjs"); const errors = require("../errors.cjs"); const changeEvents = require("./change-events.cjs"); function createReplayCompletion() { const completion = deferred.createDeferred(); void completion.promise.catch(() => { }); return completion; } function cancelAcquisition(acquisition) { acquisition.abortController?.abort(); acquisition.removeRequestAbortListener?.(); } class CollectionSubscription extends eventEmitter.EventEmitter { constructor(collection, callback, options) { super(); this.collection = collection; this.callback = callback; this.options = options; this.loadedInitialState = false; this.skipFiltering = false; this.snapshotSent = false; this.subsetDemands = []; this.primaryFailureDeliveryDepth = 0; this.requestedSubsetWhere = /* @__PURE__ */ new WeakMap(); this.sentKeys = /* @__PURE__ */ new Set(); this.publishedRows = /* @__PURE__ */ new Map(); this.stalePublishedRows = /* @__PURE__ */ new Map(); this.limitedSnapshotRowCount = 0; this._status = `ready`; this.statusRevision = 0; this.pendingLoadSubsetParticipants = /* @__PURE__ */ new Set(); this.loadSubsetPromiseErrors = /* @__PURE__ */ new WeakMap(); this.truncateReplacementPending = false; this.unsubscribed = false; if (options.onUnsubscribe) { this.on(`unsubscribed`, options.onUnsubscribe); } if (options.onLoadSubsetError) { this.on(`loadSubset:error`, options.onLoadSubsetError); } if (options.whereExpression) { autoIndex.ensureIndexForExpression(options.whereExpression, this.collection); } const callbackWithSentKeysTracking = (changes) => { this.trackPublishedRows(changes); this.trackSentKeys(changes); callback(changes); }; this.callback = callbackWithSentKeysTracking; this.filteredCallback = options.whereExpression ? changeEvents.createFilteredCallback(this.callback, options) : (changes) => { this.callback(changes); return true; }; this.truncateCleanup = this.collection.on(`truncate`, () => { this.handleTruncate(); }); this.collectionCleanup = this.collection.on(`status:cleaned-up`, () => { this.handleCollectionCleanup(); }); this.collectionRestartCleanup = this.collection.on( `status:change`, ({ status }) => { if (status !== `loading` && status !== `ready`) return; const loadSubsetSession = this.collection._sync.getLoadSubsetSession(); const replaySession = this.truncateReplaySession; if (this.subsetDemands.some( (demand) => demand.acquisitionState === `detached` )) { this.setStatus(`loadingSubset`); } queueMicrotask(() => { if (this.truncateReplaySession === replaySession) { this.restartDetachedDemands(loadSubsetSession); } }); } ); } get status() { return this._status; } get lastError() { return this._lastError; } /** Detach logical demand from work owned by a discarded sync session. */ handleCollectionCleanup() { this.discardTruncateReplay(); this.stalePublishedRows = new Map(this.publishedRows); this.pendingLoadSubsetParticipants.clear(); for (const demand of [...this.subsetDemands]) { demand.initialResult?.reject(new errors.LoadSubsetOperationAbortedError()); cancelAcquisition(demand.acquisition); if (demand.acquisitionState === `starting`) { const index = this.subsetDemands.indexOf(demand); if (index !== -1) this.subsetDemands.splice(index, 1); } else { demand.acquisitionState = `detached`; demand.acquisition = { options: demand.requestOptions, loadSubsetSession: demand.acquisition.loadSubsetSession }; } } this.setReadyIfIdle(); } /** Acquire detached demand after startup or initial-error recovery. */ restartDetachedDemands(loadSubsetSession) { if (this.unsubscribed || !this.isLoadSubsetSessionCurrent(loadSubsetSession)) { return; } if (this.collection.status === `error` || this.collection._sync.syncLoadSubsetFn === null) { this.setReadyIfIdle(); return; } const demands = this.subsetDemands.filter( (demand) => demand.acquisitionState === `detached` && !demand.requestOptions.signal?.aborted ); if (demands.length === 0) { this.setReadyIfIdle(); return; } const session = this.createTruncateReplaySession(loadSubsetSession, () => { const currentRows = this.collection.currentStateAsChanges({ optimizedOnly: false }); return new Map( // The API returns void for unavailable snapshots, not just undefined. // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition (currentRows ?? []).filter((change) => change.type !== `delete`).map((change) => [change.key, change.value]) ); }); const attempt = session.currentAttempt; this.truncateReplaySession = session; this.setStatus(`loadingSubset`); if (this.truncateReplaySession !== session) return; this.startTruncateReplayAttempt(session, attempt, demands); } /** * Handle collection truncate event by resetting state and re-requesting subsets. * This is called when the sync layer receives a must-refetch and clears all data. * * To prevent a flash of missing content, we buffer all changes (deletes from truncate * and inserts from refetch) until all loadSubset calls succeed, then emit them together. * A failed replay keeps the last published snapshot private until a later * authoritative replay succeeds. */ handleTruncate() { const hasLoadSubsetHandler = this.collection._sync.syncLoadSubsetFn !== null; const demandsToReload = hasLoadSubsetHandler ? [...this.subsetDemands] : []; if (demandsToReload.length === 0 && this.stalePublishedRows.size === 0) { this.resetSnapshotTracking(); return; } let session = this.truncateReplaySession; if (session) { if (!session.completion.isPending()) { session.completion = createReplayCompletion(); } session.pendingSetups++; session.failures.clear(); session.currentAttempt = { pendingCount: 0, setupComplete: false }; } else { session = this.createTruncateReplaySession( this.collection._sync.getLoadSubsetSession(), () => new Map(this.publishedRows) ); this.truncateReplaySession = session; } const attempt = session.currentAttempt; this.setStatus(`loadingSubset`); if (this.truncateReplaySession !== session) return; if (this.options.truncateReplayPublication) { this.truncateReplacementPending = true; this.options.truncateReplayPublication.start(); } for (const demand of demandsToReload) { demand.acquisition.abortController?.abort(); } this.resetSnapshotTracking(); queueMicrotask(() => { if (this.truncateReplaySession !== session) return; if (!this.isLoadSubsetSessionCurrent(session.loadSubsetSession)) { this.retireStaleTruncateReplay(session); return; } this.startTruncateReplayAttempt( session, attempt, session.currentAttempt === attempt ? demandsToReload : [] ); }); } /** Make tentative replay ownership visible before adapter code can reenter. */ startTruncateReplayDemand(session, attempt, demand) { const isCurrentAttempt = () => this.truncateReplaySession === session && session.currentAttempt === attempt; const isCurrent = () => isCurrentAttempt() && this.isLoadSubsetSessionCurrent(session.loadSubsetSession) && this.isDemandActive(demand); const fail = (error$1) => { if (isCurrent()) session.failures.set(demand, error.normalizeError(error$1)); }; if (demand.initialResult) { void session.completion.promise.then( demand.initialResult.resolve, demand.initialResult.reject ); } const previous = demand.acquisition; const hadPreviousAcquisition = demand.acquisitionState === `active`; demand.acquisitionState = `detached`; if (hadPreviousAcquisition) { try { this.releaseAcquisition(previous); } catch (error2) { fail(error2); return; } } if (!isCurrent() || demand.requestOptions.signal?.aborted) return; const next = this.createSubsetAcquisition(demand); demand.acquisition = next; demand.acquisitionState = `starting`; let result; try { result = this.loadSubset(next.options, isCurrent); } catch (error2) { if (demand.acquisition === next) demand.acquisitionState = `detached`; cancelAcquisition(next); fail(error2); return; } if (!isCurrent()) { if (demand.acquisition === next) demand.acquisitionState = `detached`; try { this.releaseAcquisition(next); } catch (error2) { fail(error2); } return; } demand.acquisitionState = `active`; this.trackTruncateReplayParticipant(session, attempt, demand, result); this.observeLoadSubsetResult( result, demand, next.options, true, () => isCurrent() && !next.options.signal?.aborted ); } settleTruncateReplay(session, pending) { try { if (this.truncateReplaySession !== session) return; if (!this.isLoadSubsetSessionCurrent(session.loadSubsetSession)) { this.retireStaleTruncateReplay(session); return; } if (session.pending.delete(pending)) pending.attempt.pendingCount--; this.checkTruncateReplayComplete(session); } catch (error2) { queueMicrotask(() => { throw error2; }); } } /** Keep every acquisition begun during recovery inside its publication barrier. */ trackTruncateReplayParticipant(session, attempt, demand, result) { if (this.truncateReplaySession !== session || session.currentAttempt !== attempt && attempt.setupComplete && attempt.pendingCount === 0 || !(result instanceof Promise)) { return; } const pending = { demand, attempt }; attempt.pendingCount++; session.pending.add(pending); void result.then( () => this.settleTruncateReplay(session, pending), (error2) => { if (this.truncateReplaySession === session && session.currentAttempt === attempt && this.isLoadSubsetSessionCurrent(session.loadSubsetSession) && this.subsetDemands.includes(demand)) { const normalized = this.normalizeLoadSubsetPromiseError(result, error2); session.failures.set(demand, normalized); } this.settleTruncateReplay(session, pending); } ); } /** Stop obsolete logical demand from pinning a replay barrier. */ removeTruncateReplayParticipant(demand) { const session = this.truncateReplaySession; if (!session) return; session.failures.delete(demand); for (const pending of session.pending) { if (pending.demand === demand) { session.pending.delete(pending); pending.attempt.pendingCount--; } } } /** Publish only after every overlapping replay attempt has settled. */ checkTruncateReplayComplete(session) { if (this.truncateReplaySession !== session) return; if (session.pendingSetups > 0 || session.pending.size > 0) return; const activeFailure = [...session.failures].find( ([demand]) => this.subsetDemands.includes(demand) ); try { if (activeFailure) { this.abandonTruncateReplay(session, activeFailure[1]); } else { this.flushTruncateReplay(session); } } finally { this.setReadyIfIdle(); } } /** * Keep an incomplete replay private. The source no longer proves a complete * state, so only a later successful truncate replay may reopen publication. */ abandonTruncateReplay(session, failure) { if (this.truncateReplaySession !== session) return; session.completion.reject(failure); if (!session.privateRows) return; const publicationState = session.publicationState; this.loadedInitialState = publicationState.loadedInitialState; this.snapshotSent = publicationState.snapshotSent; this.limitedSnapshotRowCount = publicationState.limitedSnapshotRowCount; this.lastSentKey = publicationState.lastSentKey; } /** Publish the buffered replacement as one batch, or release the delegate. */ flushTruncateReplay(session) { if (this.truncateReplaySession !== session) return; this.truncateReplaySession = void 0; this.truncateReplacementPending = false; const { privateRows } = session; for (const key of this.stalePublishedRows.keys()) privateRows?.delete(key); this.stalePublishedRows.clear(); try { if (privateRows) { const replacement = this.createStateDiff( this.publishedRows, privateRows ); if (replacement.length > 0) this.filteredCallback(replacement); } } finally { this.restorePublishedSnapshotTracking(); session.completion.resolve(); this.options.truncateReplayPublication?.succeed(); } } restorePublishedSnapshotTracking() { this.sentKeys = new Set(this.publishedRows.keys()); if (!this.orderByIndex) return; this.limitedSnapshotRowCount = this.sentKeys.size; const orderedSentKeys = this.orderByIndex.takeFromStart( this.sentKeys.size, (key) => this.sentKeys.has(key) ); this.lastSentKey = orderedSentKeys.at(-1); } /** Fold changes into the private replacement; false when they publish now. */ bufferPrivately(changes) { const privateRows = this.truncateReplaySession?.privateRows; if (!privateRows) return false; for (const change of changes) { if (change.type === `delete`) privateRows.delete(change.key); else privateRows.set(change.key, change.value); } return true; } createStateDiff(baseline, finalRows) { const replacement = []; for (const [key, previousValue] of baseline) { const value = finalRows.get(key); if (value === void 0) { replacement.push({ type: `delete`, key, value: previousValue }); } else if (!utils.deepEquals(value, previousValue)) { replacement.push({ type: `update`, key, value, previousValue }); } } for (const [key, value] of finalRows) { if (!baseline.has(key)) replacement.push({ type: `insert`, key, value }); } return replacement; } get isBufferingForTruncate() { return this.truncateReplaySession !== void 0; } setReadyIfIdle() { const session = this.truncateReplaySession; const hasPendingReplayWork = session && (session.pendingSetups > 0 || session.pending.size > 0); if (this.pendingLoadSubsetParticipants.size === 0 && !hasPendingReplayWork) { this.setStatus(`ready`); } } isLoadSubsetSessionCurrent(session) { return session === this.collection._sync.getLoadSubsetSession(); } retireStaleTruncateReplay(session) { if (this.truncateReplaySession !== session) return; this.discardTruncateReplay(); this.stalePublishedRows.clear(); } /** Drop the replay without publishing; an unfinished wait rejects as aborted. */ discardTruncateReplay() { const session = this.truncateReplaySession; if (session?.completion.isPending()) { session.completion.reject(new errors.LoadSubsetOperationAbortedError()); } this.truncateReplaySession = void 0; this.truncateReplacementPending = false; } resetSnapshotTracking() { this.snapshotSent = false; this.loadedInitialState = false; this.limitedSnapshotRowCount = 0; this.lastSentKey = void 0; } /** One replay session; only direct subscribers buffer a private replacement. */ createTruncateReplaySession(loadSubsetSession, privateRows) { return { loadSubsetSession, publicationState: { loadedInitialState: this.loadedInitialState, snapshotSent: this.snapshotSent, limitedSnapshotRowCount: this.limitedSnapshotRowCount, lastSentKey: this.lastSentKey }, privateRows: this.options.truncateReplayPublication ? void 0 : privateRows(), pending: /* @__PURE__ */ new Set(), // Setup itself holds publication: adapter/status callbacks may reenter // before a request returns its promise and joins the pending set. pendingSetups: 1, currentAttempt: { pendingCount: 0, setupComplete: false }, failures: /* @__PURE__ */ new Map(), completion: createReplayCompletion() }; } /** Start one attempt's demands, then release the setup hold on publication. */ startTruncateReplayAttempt(session, attempt, demands) { for (const demand of demands) { if (!this.subsetDemands.includes(demand)) continue; this.startTruncateReplayDemand(session, attempt, demand); if (this.truncateReplaySession !== session || session.currentAttempt !== attempt) { break; } } attempt.setupComplete = true; session.pendingSetups--; this.checkTruncateReplayComplete(session); } get hasPendingTruncateReplacement() { return this.truncateReplacementPending; } get pendingTruncateReplacement() { const completion = this.truncateReplaySession?.completion; return completion?.isPending() ? completion.promise : void 0; } get hasFailedTruncateReplacement() { const completion = this.truncateReplaySession?.completion; return this.truncateReplacementPending && completion !== void 0 && !completion.isPending(); } setOrderByIndex(index) { this.orderByIndex = index; } /** * Set subscription status and emit events if changed */ setStatus(newStatus) { if (this.unsubscribed) return; if (this._status === newStatus) { return; } const previousStatus = this._status; this._status = newStatus; const revision = ++this.statusRevision; this.emitInnerWhile( `status:change`, { type: `status:change`, subscription: this, previousStatus, status: newStatus }, () => this.statusRevision === revision ); if (this.statusRevision !== revision) return; const eventKey = `status:${newStatus}`; this.emitInnerWhile( eventKey, { type: eventKey, subscription: this, previousStatus, status: newStatus }, () => this.statusRevision === revision ); } /** Observe an asynchronous subset load and restore status on settlement. */ observeLoadSubsetResult(syncResult, demand, options, trackStatus, shouldReportError = () => true) { if (!(syncResult instanceof Promise)) return; const loadSubsetSession = this.collection._sync.getLoadSubsetSession(); const participant = { demand, promise: syncResult }; if (trackStatus) { this.pendingLoadSubsetParticipants.add(participant); this.setStatus(`loadingSubset`); } const finish = () => { if (trackStatus) { this.pendingLoadSubsetParticipants.delete(participant); if (this.isLoadSubsetSessionCurrent(loadSubsetSession)) { this.setReadyIfIdle(); } } }; void syncResult.then(finish, (error2) => { if (this.isLoadSubsetSessionCurrent(loadSubsetSession) && shouldReportError()) { this.recordLoadSubsetError( options, this.normalizeLoadSubsetPromiseError(syncResult, error2) ); } finish(); }); } /** Give every logical observer of one transport rejection the same Error. */ normalizeLoadSubsetPromiseError(promise, error$1) { const existing = this.loadSubsetPromiseErrors.get(promise); if (existing) return existing; const normalized = error.normalizeError(error$1); this.loadSubsetPromiseErrors.set(promise, normalized); return normalized; } stopDemandStatusParticipants(demand) { for (const participant of this.pendingLoadSubsetParticipants) { if (participant.demand === demand) { this.pendingLoadSubsetParticipants.delete(participant); } } this.setReadyIfIdle(); } loadSubset(options, shouldReportError = () => true) { try { return this.collection._sync.loadSubset(options); } catch (error$1) { const normalized = error.normalizeError(error$1); if (shouldReportError()) this.recordLoadSubsetError(options, normalized); throw normalized; } } /** Create a fresh, abortable adapter acquisition for a replay generation. */ createSubsetAcquisition(demand) { const abortController = new AbortController(); const requestSignal = demand.requestOptions.signal; let removeRequestAbortListener; if (requestSignal?.aborted) { abortController.abort(requestSignal.reason); } else if (requestSignal) { const abort = () => abortController.abort(requestSignal.reason); requestSignal.addEventListener(`abort`, abort, { once: true }); removeRequestAbortListener = () => requestSignal.removeEventListener(`abort`, abort); } return { options: { ...demand.requestOptions, signal: abortController.signal }, loadSubsetSession: this.collection._sync.getLoadSubsetSession(), abortController, removeRequestAbortListener }; } /** Retire an acquisition before user code; failed cleanup is not retryable. */ releaseAcquisition(acquisition, reportReleaseError = this.primaryFailureDeliveryDepth === 0) { if (acquisition.releaseAttempted) return; acquisition.releaseAttempted = true; try { acquisition.abortController?.abort(); if (this.isLoadSubsetSessionCurrent(acquisition.loadSubsetSession)) { this.collection._sync.unloadSubset(acquisition.options); } } catch (error$1) { const normalized = reportReleaseError ? this.recordLoadSubsetError( acquisition.options, error.normalizeError(error$1), true ) : error.normalizeError(error$1); throw normalized; } finally { acquisition.removeRequestAbortListener?.(); } } /** Start and retain the first acquisition for one logical subset demand. */ startSubsetDemand(requestOptions) { const demand = { requestOptions, acquisition: { options: requestOptions, loadSubsetSession: this.collection._sync.getLoadSubsetSession() }, acquisitionState: `starting` }; if (this.collection.status === `cleaned-up` || // Ready/error callbacks can run before sync returns its loader. Idle // deferred starts still acquire through the sync manager's queue. this.collection.config.syncMode === `on-demand` && (this.collection.status === `error` || this.collection.status !== `idle` && this.collection._sync.syncLoadSubsetFn === null)) { demand.acquisitionState = `detached`; this.subsetDemands.push(demand); const initialResult = deferred.createDeferred(); demand.initialResult = initialResult; const abort = () => initialResult.reject(new errors.LoadSubsetOperationAbortedError()); requestOptions.signal?.addEventListener(`abort`, abort, { once: true }); const finish = () => { requestOptions.signal?.removeEventListener(`abort`, abort); demand.initialResult = void 0; }; void initialResult.promise.then(finish, finish); return { demand, result: initialResult.promise, started: false }; } const acquisition = this.createSubsetAcquisition(demand); demand.acquisition = acquisition; const replaySession = this.truncateReplaySession; const replayAttempt = replaySession?.currentAttempt; const loadSubsetSession = this.collection._sync.getLoadSubsetSession(); this.subsetDemands.push(demand); let result; try { result = this.loadSubset( acquisition.options, () => this.isLoadSubsetSessionCurrent(loadSubsetSession) && this.subsetDemands.includes(demand) && (replaySession === void 0 || this.truncateReplaySession === replaySession && replaySession.currentAttempt === replayAttempt) ); } catch (error$1) { const demandIndex = this.subsetDemands.indexOf(demand); if (demandIndex !== -1) { if (replaySession && replayAttempt && this.truncateReplaySession === replaySession && replaySession.currentAttempt === replayAttempt) { replaySession.failures.set(demand, error.normalizeError(error$1)); } this.subsetDemands.splice(demandIndex, 1); } cancelAcquisition(acquisition); throw error$1; } if (!this.isLoadSubsetSessionCurrent(loadSubsetSession)) { const demandIndex = this.subsetDemands.indexOf(demand); if (demandIndex !== -1) this.subsetDemands.splice(demandIndex, 1); cancelAcquisition(acquisition); return { demand, result, started: true }; } demand.acquisitionState = `active`; if (!this.subsetDemands.includes(demand)) { this.releaseAcquisition(acquisition); return { demand, result, started: true }; } if (replaySession && replayAttempt) { this.trackTruncateReplayParticipant( replaySession, replayAttempt, demand, result ); } return { demand, result, started: true }; } /** Re-check ownership after adapter and event callbacks that may reenter. */ isDemandActive(demand) { return !this.unsubscribed && this.subsetDemands.includes(demand); } recordLoadSubsetError(options, error$1, reportAborted = false) { const normalized = error.normalizeError(error$1); if (options.signal?.aborted && !reportAborted) return normalized; this._lastError = normalized; this.primaryFailureDeliveryDepth++; try { this.emitInner(`loadSubset:error`, { type: `loadSubset:error`, subscription: this, options, error: normalized }); } finally { this.primaryFailureDeliveryDepth--; } return normalized; } emitEvents(changes) { if (this.unsubscribed) return false; const newChanges = this.filterAndFlipChanges(changes); if (changes.length > 0 && newChanges.length === 0) return false; if (this.bufferPrivately(newChanges)) return false; return this.filteredCallback(newChanges); } /** Keep direct snapshot reads private while an authoritative replay is open. */ publishSnapshot(changes) { if (!this.bufferPrivately(changes)) this.callback(changes); } /** * Sends the snapshot to the callback. * Returns a boolean indicating if it succeeded. * It can only fail if there is no index to fulfill the request * and the optimizedOnly option is set to true, * or, the entire state was already loaded or the request was cancelled. */ requestSnapshot(opts) { if (this.unsubscribed || opts?.signal?.aborted) return false; if (this.loadedInitialState) { return false; } const stateOpts = { where: this.options.whereExpression, optimizedOnly: opts?.optimizedOnly ?? false }; if (opts) { if (`where` in opts) { const snapshotWhereExp = opts.where; if (stateOpts.where) { const subWhereExp = stateOpts.where; const combinedWhereExp = functions.and(subWhereExp, snapshotWhereExp); stateOpts.where = combinedWhereExp; } else { stateOpts.where = snapshotWhereExp; } } } else { this.loadedInitialState = true; } const loadOptions = { where: stateOpts.where, signal: opts?.signal, subscription: this, // Include orderBy and limit if provided so sync layer can optimize the query orderBy: opts?.orderBy, limit: opts?.limit }; const { demand, result: syncResult, started } = this.startSubsetDemand(loadOptions); if (!this.isDemandActive(demand)) return false; if (opts?.where) this.requestedSubsetWhere.set(loadOptions, opts.where); opts?.onLoadSubsetResult?.( syncResult, demand.acquisition.options, (primaryFailure) => this.releaseDemand(demand, primaryFailure) ); if (!this.isDemandActive(demand)) return false; if (started) { this.observeLoadSubsetResult( syncResult, demand, demand.acquisition.options, opts?.trackLoadSubsetPromise ?? true ); } if (!this.isDemandActive(demand)) return false; let snapshot; if (opts?.onUnoptimized) { snapshot = this.collection.currentStateAsChanges({ ...stateOpts, optimizedOnly: true }); if (snapshot === void 0) { opts.onUnoptimized(); if (this.unsubscribed) return false; snapshot = this.collection.currentStateAsChanges({ ...stateOpts, optimizedOnly: false }); } } else { snapshot = this.collection.currentStateAsChanges(stateOpts); } if (this.unsubscribed) return false; if (snapshot === void 0) { return false; } const knownRows = this.truncateReplaySession?.privateRows ?? this.publishedRows; const filteredSnapshot = snapshot.filter( (change) => !this.isBufferingForTruncate && this.stalePublishedRows.has(change.key) || !this.sentKeys.has(change.key) && !knownRows.has(change.key) ); for (const change of filteredSnapshot) { this.sentKeys.add(change.key); } this.snapshotSent = true; this.publishSnapshot( this.isBufferingForTruncate ? filteredSnapshot : this.reconcileStalePublishedChanges(filteredSnapshot) ); return true; } /** Release one exact subset request while keeping the subscription alive. */ releaseSnapshot(where) { const index = this.subsetDemands.findIndex( (demand) => demand.requestOptions.where === where || this.requestedSubsetWhere.get(demand.requestOptions) === where ); if (index === -1) return; this.releaseDemandAt(index); } releaseDemand(demand, primaryFailure) { if (!primaryFailure) { const index = this.subsetDemands.indexOf(demand); if (index !== -1) this.releaseDemandAt(index); return; } try { this.recordLoadSubsetError( demand.acquisition.options, primaryFailure.error, true ); } finally { const index = this.subsetDemands.indexOf(demand); if (index !== -1) this.releaseDemandAt(index, false); } } releaseDemandAt(index, reportReleaseError = this.primaryFailureDeliveryDepth === 0) { const demand = this.subsetDemands[index]; if (!demand) return; const replaySession = this.truncateReplaySession; const acquisition = demand.acquisition; this.subsetDemands.splice(index, 1); demand.initialResult?.reject(new errors.LoadSubsetOperationAbortedError()); const releaseCallbacks = [ () => this.removeTruncateReplayParticipant(demand), ...demand.acquisitionState === `active` ? [ // Adapter release is a supported reentrancy boundary. A demand // started from unload joins this replacement before completion. () => this.releaseAcquisition(acquisition, reportReleaseError) ] : [], () => this.retireEmptyReplay(), () => { if (replaySession) this.checkTruncateReplayComplete(replaySession); }, // Ready follows replacement publication, never the delete half of it. () => this.stopDemandStatusParticipants(demand) ]; callbacks.runAllCallbacks(releaseCallbacks); } /** A replay with no remaining logical demand cannot establish more rows. */ retireEmptyReplay() { if (this.subsetDemands.length !== 0 || !this.truncateReplaySession) { return; } this.discardTruncateReplay(); this.stalePublishedRows = new Map(this.publishedRows); this.restorePublishedSnapshotTracking(); this.options.truncateReplayPublication?.succeed(); } /** Read the applied rows in an ordered acquisition without starting demand. */ readOrderedSnapshot(options) { const predicates = [ this.options.whereExpression, options.where, options.cursor?.whereFrom ].filter((where) => where !== void 0); const snapshot = this.collection.currentStateAsChanges({ orderBy: options.orderBy, limit: options.limit, where: predicates.length > 0 ? predicates.reduce((left, right) => functions.and(left, right)) : void 0 }); return Array.isArray(snapshot) ? snapshot : []; } /** * Sends a snapshot that fulfills the `where` clause and all rows are bigger or equal to the cursor. * Requires a range index to be set with `setOrderByIndex` prior to calling this method. * It uses that range index to load the items in the order of the index. * * Cursor requests support one order term and one minValue. Multi-column * queries use the ordered loader's prefix-and-tie fallback instead. * * Note 1: it may load more rows than the provided LIMIT because it loads all values equal to the first cursor value + limit values greater. * This is needed to ensure that it does not accidentally skip duplicate values when the limit falls in the middle of some duplicated values. * Note 2: it does not send keys that have already been sent before. */ requestLimitedSnapshot({ orderBy, limit, minValues, offset, trackLoadSubsetPromise: shouldTrackLoadSubsetPromise = true, onLoadSubsetResult }) { if (this.unsubscribed) return; if (!limit) throw new Error(`limit is required`); if (!this.orderByIndex) { throw new Error( `Ordered snapshot was requested but no index was found. You have to call setOrderByIndex before requesting an ordered snapshot.` ); } const whereFromCursor = minValues ? cursor.buildCursor(orderBy, minValues) : void 0; const hasMinValue = minValues !== void 0 && minValues.length > 0; const minValue = minValues?.[0]; const minValueForIndex = minValue; const index = this.orderByIndex; const where = this.options.whereExpression; const whereFilterFn = where ? changeEvents.createFilterFunctionFromExpression(where) : void 0; const filterFn = (key) => { if (key !== void 0 && this.sentKeys.has(key)) { return false; } const value = this.collection.get(key); if (value === void 0) { return false; } return whereFilterFn?.(value) ?? true; }; let biggestObservedValue = minValueForIndex; const changes = []; let keys = []; if (hasMinValue) { const { expression } = orderBy[0]; const allRowsWithMinValue = this.collection.currentStateAsChanges({ where: functions.eq(expression, new ir.Value(minValueForIndex)) }); if (allRowsWithMinValue) { const keysWithMinValue = allRowsWithMinValue.map((change) => change.key).filter((key) => !this.sentKeys.has(key) && filterFn(key)); keys.push(...keysWithMinValue); const keysGreaterThanMin = index.take( limit - keys.length, minValueForIndex, filterFn ); keys.push(...keysGreaterThanMin); } else { keys = index.take(limit, minValueForIndex, filterFn); } } else { keys = index.takeFromStart(limit, filterFn); } const valuesNeeded = () => Math.max(limit - changes.length, 0); const collectionExhausted = () => keys.length === 0; const orderByExpression = orderBy[0].expression; const valueExtractor = orderByExpression.type === `ref` ? evaluators.compileExpression(new ir.PropRef(orderByExpression.path), true) : null; while (valuesNeeded() > 0 && !collectionExhausted()) { for (const key of keys) { const value = this.collection.get(key); changes.push({ type: `insert`, key, value }); biggestObservedValue = valueExtractor ? valueExtractor(value) : value; } keys = index.take(valuesNeeded(), biggestObservedValue, filterFn); } const currentOffset = this.limitedSnapshotRowCount; for (const change of changes) { this.sentKeys.add(change.key); } this.publishSnapshot(changes); if (this.unsubscribed) return; this.limitedSnapshotRowCount = Math.max( this.limitedSnapshotRowCount, currentOffset + changes.length ); if (changes.length > 0) { this.lastSentKey = changes[changes.length - 1].key; } let cursorExpressions; if (whereFromCursor && minValues) { const whereCurrentCursor = cursor.buildCursorCurrent(orderBy, minValues); if (whereCurrentCursor) { cursorExpressions = { whereFrom: whereFromCursor, whereCurrent: whereCurrentCursor, lastKey: this.lastSentKey }; } } const loadOptions = { where, // Main filter only, no cursor limit, orderBy, cursor: cursorExpressions, // Cursor expressions passed separately offset: offset ?? currentOffset, // Use provided offset, or auto-tracked offset subscription: this }; const { demand, result: syncResult, started } = this.startSubsetDemand(loadOptions); if (!this.isDemandActive(demand)) return; onLoadSubsetResult?.( syncResult, demand.acquisition.options, (primaryFailure) => this.releaseDemand(demand, primaryFailure) ); if (!this.isDemandActive(demand)) return; if (started) { this.observeLoadSubsetResult( syncResult, demand, demand.acquisition.options, shouldTrackLoadSubsetPromise ); } if (!this.isDemandActive(demand)) return; } // TODO: also add similar test but that checks that it can also load it from the collection's loadSubset function // and that that also works properly (i.e. does not skip duplicate values) /** * Filters and flips changes for keys that have not been sent yet. * Deletes are filtered out for keys that have not been sent yet. * Updates are flipped into inserts for keys that have not been sent yet. * Duplicate inserts are filtered out to prevent D2 multiplicity > 1. */ filterAndFlipChanges(changes) { changes = this.reconcileStalePublishedChanges(changes); if (this.loadedInitialState || this.skipFiltering) { return changes; } const skipDeleteFilter = this.isBufferingForTruncate; const newChanges = []; for (const change of changes) { let newChange = change; const keyInSentKeys = this.sentKeys.has(change.key); if (!keyInSentKeys) { if (change.type === `update`) { newChange = { ...change, type: `insert`, previousValue: void 0 }; this.sentKeys.add(change.key); } else if (change.type === `delete`) { if (!skipDeleteFilter) { continue; } } else { this.sentKeys.add(change.key); } } else { if (change.type === `insert`) { continue; } else if (change.type === `delete`) { this.sentKeys.delete(change.key); } } newChanges.push(newChange); } return newChanges; } /** * After a failed replay, the source collection is empty but subscribers still * hold the last good publication. Reconcile the first later source delta for * each retained key against that publication instead of treating it as a * duplicate insert. */ reconcileStalePublishedChanges(changes) { if (this.stalePublishedRows.size === 0) return changes; const reconciled = []; for (const change of changes) { const previous = this.stalePublishedRows.get(change.key); if (previous === void 0) { reconciled.push(change); continue; } this.stalePublishedRows.delete(change.key); if (change.type === `delete`) { reconciled.push({ ...change, value: previous, previousValue: void 0 }); } else if (!utils.deepEquals(previous, change.value)) { reconciled.push({ ...change, type: `update`, previousValue: previous }); } } if (this.collection.config.syncMode !== `on-demand` && !this.isBufferingForTruncate) { for (const [key, value] of this.stalePublishedRows) { if (this.collection.has(key)) continue; this.stalePublishedRows.delete(key); reconciled.push({ type: `delete`, key, value }); } } return reconciled; } trackPublishedRows(changes) { for (const change of changes) { if (change.type === `delete`) { this.publishedRows.delete(change.key); } else { this.publishedRows.set(change.key, change.value); } } } trackSentKeys(changes) { if (this.loadedInitialState || this.skipFiltering) { return; } for (const change of changes) { if (change.type === `delete`) { this.sentKeys.delete(change.key); } else { this.sentKeys.add(change.key); } } if (this.orderByIndex) { this.limitedSnapshotRowCount = Math.max( this.limitedSnapshotRowCount, this.sentKeys.size ); } } /** * Mark that the subscription should not filter any changes. * This is used when includeInitialState is explicitly set to false, * meaning the caller doesn't want initial state but does want ALL future changes. */ markAllStateAsSeen() { this.skipFiltering = true; } unsubscribe() { if (this.unsubscribed) return; this.unsubscribed = true; this.statusRevision++; const sourceListenerCleanups = [ this.truncateCleanup, this.collectionCleanup, this.collectionRestartCleanup ]; this.truncateCleanup = void 0; this.collectionCleanup = void 0; this.collectionRestartCleanup = void 0; callbacks.runAllCallbacks([ ...sourceListenerCleanups.map((cleanup) => () => cleanup?.()), () => { this.discardTruncateReplay(); this.stalePublishedRows.clear(); const acquisitions = this.subsetDemands.filter((demand) => demand.acquisitionState === `active`).map((demand) => demand.acquisition); for (const demand of this.subsetDemands) { demand.initialResult?.reject(new errors.LoadSubsetOperationAbortedError()); this.stopDemandStatusParticipants(demand); if (demand.acquisitionState === `starting`) { cancelAcquisition(demand.acquisition); } } this.subsetDemands = []; callbacks.runAllCallbacks( acquisitions.map( (acquisition) => () => this.releaseAcquisition(acquisition) ) ); }, () => this.emitInner(`unsubscribed`, { type: `unsubscribed`, subscription: this }), // Clear all event listeners to prevent memory leaks () => this.clearListeners() ]); } } exports.CollectionSubscription = CollectionSubscription; //# sourceMappingURL=subscription.cjs.map