UNPKG

@tanstack/db

Version:

A reactive client store for building super fast apps on sync

1,209 lines (1,208 loc) • 40.2 kB
import { ensureIndexForExpression } from "../indexes/auto-index.js"; import { and, eq } from "../query/builder/functions.js"; import { Value, PropRef, getPropRefPropertyPath } from "../query/ir.js"; import { EventEmitter } from "../event-emitter.js"; import { compileExpression } from "../query/compiler/evaluators.js"; import { buildCursor, buildCursorCurrent } from "../utils/cursor.js"; import { deepEquals } from "../utils.js"; import { normalizeError } from "../utils/error.js"; import { runAllCallbacks } from "../utils/callbacks.js"; import { createDeferred } from "../deferred.js"; import { LoadSubsetOperationAbortedError } from "../errors.js"; import { findEqualityRoute, createFilteredCallback, createFilterFunctionFromExpression } from "./change-events.js"; function createReplayCompletion() { const completion = createDeferred(); void completion.promise.catch(() => { }); return completion; } function cancelAcquisition(acquisition) { acquisition.abortController?.abort(); acquisition.removeRequestAbortListener?.(); } class CollectionSubscription extends EventEmitter { constructor(collection, callback, options) { super(); this.collection = collection; this.callback = callback; this.options = options; this.loadedInitialState = false; this.ej = false; this.snapshotSent = false; this.C = []; this.bf = 0; this.d8 = /* @__PURE__ */ new WeakMap(); this.aC = /* @__PURE__ */ new Set(); this.ac = /* @__PURE__ */ new Map(); this.ad = /* @__PURE__ */ new Map(); this.limitedSnapshotRowCount = 0; this.fk = `ready`; this.aV = 0; this.aq = /* @__PURE__ */ new Set(); this.dx = /* @__PURE__ */ new WeakMap(); this.aR = false; this.unsubscribed = false; if (options.onUnsubscribe) { this.on(`unsubscribed`, options.onUnsubscribe); } if (options.onLoadSubsetError) { this.on(`loadSubset:error`, options.onLoadSubsetError); } if (options.whereExpression) { ensureIndexForExpression(options.whereExpression, this.collection); } const callbackWithSentKeysTracking = (changes) => { this.fj(changes); this.e9(changes); callback(changes); }; this.callback = callbackWithSentKeysTracking; this.equalityRoute = options.whereExpression ? findEqualityRoute(options.whereExpression) : void 0; this.dN = options.whereExpression ? createFilteredCallback(this.callback, options) : (changes) => { this.callback(changes); return true; }; this.ec = this.collection.on(`truncate`, () => { this.fC(); }); this.dB = this.collection._onCleanupStart(() => { this.eE(); }); this.cb = this.collection.on( `status:change`, ({ status }) => { if (status !== `loading` && status !== `ready`) return; const syncRunGeneration = this.collection._sync.getSyncRunGeneration(); const replayState = this.o; if (this.C.some( (demand) => demand.acquisitionState === `detached` )) { this.setStatus(`loadingSubset`); } queueMicrotask(() => { if (this.o === replayState) { this.eM(syncRunGeneration); } }); } ); } get status() { return this.fk; } get lastError() { return this.fu; } /** Detach logical demand from work owned by a discarded sync run. */ eE() { this.bP(); this.ad = new Map(this.ac); this.aq.clear(); for (const demand of [...this.C]) { demand.initialResult?.reject(new LoadSubsetOperationAbortedError()); cancelAcquisition(demand.acquisition); if (demand.acquisitionState === `starting`) { const index = this.C.indexOf(demand); if (index !== -1) this.C.splice(index, 1); } else { demand.acquisitionState = `detached`; demand.acquisition = { options: demand.requestOptions, syncRunGeneration: demand.acquisition.syncRunGeneration }; } } this.b9(); } /** Acquire detached demand after startup or initial-error recovery. */ eM(syncRunGeneration) { if (this.unsubscribed || !this.Z(syncRunGeneration)) { return; } if (this.collection.status === `error` || this.collection._sync.syncLoadSubsetFn === null) { this.b9(); return; } const demands = this.C.filter( (demand) => demand.acquisitionState === `detached` && !demand.requestOptions.signal?.aborted ); if (demands.length === 0) { this.b9(); return; } const replayState = this.c4( syncRunGeneration, () => { 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 = replayState.currentAttempt; this.o = replayState; this.setStatus(`loadingSubset`); if (this.o !== replayState) return; this.cX(replayState, 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. */ fC() { const hasLoadSubsetHandler = this.collection._sync.syncLoadSubsetFn !== null; const demandsToReload = hasLoadSubsetHandler ? [...this.C] : []; if (demandsToReload.length === 0 && this.ad.size === 0) { this.dU(); return; } let replayState = this.o; if (replayState) { if (!replayState.completion.isPending()) { replayState.completion = createReplayCompletion(); } replayState.pendingSetups++; replayState.failures.clear(); replayState.currentAttempt = { pendingCount: 0, setupComplete: false }; } else { replayState = this.c4( this.collection._sync.getSyncRunGeneration(), () => new Map(this.ac) ); this.o = replayState; } const attempt = replayState.currentAttempt; this.setStatus(`loadingSubset`); if (this.o !== replayState) return; if (this.options.truncateReplayPublication) { this.aR = true; this.options.truncateReplayPublication.start(); } for (const demand of demandsToReload) { demand.acquisition.abortController?.abort(); } this.dU(); queueMicrotask(() => { if (this.o !== replayState) return; if (!this.Z(replayState.syncRunGeneration)) { this.c8(replayState); return; } this.cX( replayState, attempt, replayState.currentAttempt === attempt ? demandsToReload : [] ); }); } /** Make tentative replay ownership visible before adapter code can reenter. */ eu(replayState, attempt, demand) { const isCurrentAttempt = () => this.o === replayState && replayState.currentAttempt === attempt; const isCurrent = () => isCurrentAttempt() && this.Z(replayState.syncRunGeneration) && this.a4(demand); const fail = (error) => { if (isCurrent()) replayState.failures.set(demand, normalizeError(error)); }; if (demand.initialResult) { void replayState.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.al(previous); } catch (error) { fail(error); return; } } if (!isCurrent() || demand.requestOptions.signal?.aborted) return; const next = this.cF(demand); demand.acquisition = next; demand.acquisitionState = `starting`; let result; try { result = this.loadSubset(next.options, isCurrent); } catch (error) { if (demand.acquisition === next) demand.acquisitionState = `detached`; cancelAcquisition(next); fail(error); return; } if (!isCurrent()) { if (demand.acquisition === next) demand.acquisitionState = `detached`; try { this.al(next); } catch (error) { fail(error); } return; } demand.acquisitionState = `active`; this.cB(replayState, attempt, demand, result); this.co( result, demand, next.options, true, () => isCurrent() && !next.options.signal?.aborted ); } ea(replayState, pending) { try { if (this.o !== replayState) return; if (!this.Z(replayState.syncRunGeneration)) { this.c8(replayState); return; } if (replayState.pending.delete(pending)) pending.attempt.pendingCount--; this.bJ(replayState); } catch (error) { queueMicrotask(() => { throw error; }); } } /** Keep every acquisition begun during recovery inside its publication barrier. */ cB(replayState, attempt, demand, result) { if (this.o !== replayState || replayState.currentAttempt !== attempt && attempt.setupComplete && attempt.pendingCount === 0 || !(result instanceof Promise)) { return; } const pending = { demand, attempt }; attempt.pendingCount++; replayState.pending.add(pending); void result.then( () => this.ea(replayState, pending), (error) => { if (this.o === replayState && replayState.currentAttempt === attempt && this.Z(replayState.syncRunGeneration) && this.C.includes(demand)) { const normalized = this.cl(result, error); replayState.failures.set(demand, normalized); } this.ea(replayState, pending); } ); } /** Stop obsolete logical demand from pinning a replay barrier. */ dX(demand) { const replayState = this.o; if (!replayState) return; replayState.failures.delete(demand); for (const pending of replayState.pending) { if (pending.demand === demand) { replayState.pending.delete(pending); pending.attempt.pendingCount--; } } } /** Publish only after every overlapping replay attempt has settled. */ bJ(replayState) { if (this.o !== replayState) return; if (replayState.pendingSetups > 0 || replayState.pending.size > 0) return; const activeFailure = [...replayState.failures].find( ([demand]) => this.C.includes(demand) ); try { if (activeFailure) { this.eP(replayState, activeFailure[1]); } else { this.fb(replayState); } } finally { this.b9(); } } /** * Keep an incomplete replay private. The source no longer proves a complete * state, so only a later successful truncate replay may reopen publication. */ eP(replayState, failure) { if (this.o !== replayState) return; replayState.completion.reject(failure); if (!replayState.privateRows) return; const publicationState = replayState.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. */ fb(replayState) { if (this.o !== replayState) return; this.o = void 0; this.aR = false; const { privateRows } = replayState; for (const key of this.ad.keys()) privateRows?.delete(key); this.ad.clear(); try { if (privateRows) { const replacement = this.fx( this.ac, privateRows ); if (replacement.length > 0) this.dN(replacement); } } finally { this.cg(); replayState.completion.resolve(); this.options.truncateReplayPublication?.succeed(); } } cg() { this.aC = new Set(this.ac.keys()); if (!this.orderByIndex) return; this.limitedSnapshotRowCount = this.aC.size; const orderedSentKeys = this.orderByIndex.takeFromStart( this.aC.size, (key) => this.aC.has(key) ); this.lastSentKey = orderedSentKeys.at(-1); } /** Fold changes into the private replacement; false when they publish now. */ eG(changes) { const privateRows = this.o?.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; } fx(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 (!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 fS() { return this.o !== void 0; } b9() { const replayState = this.o; const hasPendingReplayWork = replayState && (replayState.pendingSetups > 0 || replayState.pending.size > 0); if (this.aq.size === 0 && !hasPendingReplayWork) { this.setStatus(`ready`); } } Z(syncRunGeneration) { return syncRunGeneration === this.collection._sync.getSyncRunGeneration(); } c8(replayState) { if (this.o !== replayState) return; this.bP(); this.ad.clear(); } /** Drop the replay without publishing; an unfinished wait rejects as aborted. */ bP() { const replayState = this.o; if (replayState?.completion.isPending()) { replayState.completion.reject(new LoadSubsetOperationAbortedError()); } this.o = void 0; this.aR = false; } dU() { this.snapshotSent = false; this.loadedInitialState = false; this.limitedSnapshotRowCount = 0; this.lastSentKey = void 0; } /** One replay state; only direct subscribers buffer a private replacement. */ c4(syncRunGeneration, privateRows) { return { syncRunGeneration, 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. */ cX(replayState, attempt, demands) { for (const demand of demands) { if (!this.C.includes(demand)) continue; this.eu(replayState, attempt, demand); if (this.o !== replayState || replayState.currentAttempt !== attempt) { break; } } attempt.setupComplete = true; replayState.pendingSetups--; this.bJ(replayState); } get hasPendingTruncateReplacement() { return this.aR; } get pendingTruncateReplacement() { const completion = this.o?.completion; return completion?.isPending() ? completion.promise : void 0; } get hasFailedTruncateReplacement() { const completion = this.o?.completion; return this.aR && 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.fk === newStatus) { return; } const previousStatus = this.fk; this.fk = newStatus; const revision = ++this.aV; this.emitInnerWhile( `status:change`, { type: `status:change`, subscription: this, previousStatus, status: newStatus }, () => this.aV === revision ); if (this.aV !== revision) return; const eventKey = `status:${newStatus}`; this.emitInnerWhile( eventKey, { type: eventKey, subscription: this, previousStatus, status: newStatus }, () => this.aV === revision ); } /** Observe an asynchronous subset load and restore status on settlement. */ co(syncResult, demand, options, trackStatus, shouldReportError = () => true) { if (!(syncResult instanceof Promise)) return; const syncRunGeneration = this.collection._sync.getSyncRunGeneration(); const participant = { demand, promise: syncResult }; if (trackStatus) { this.aq.add(participant); this.setStatus(`loadingSubset`); } const finish = () => { if (trackStatus) { this.aq.delete(participant); if (this.Z(syncRunGeneration)) { this.b9(); } } }; void syncResult.then(finish, (error) => { if (this.Z(syncRunGeneration) && shouldReportError()) { this.bR( options, this.cl(syncResult, error) ); } finish(); }); } /** Give every logical observer of one transport rejection the same Error. */ cl(promise, error) { const existing = this.dx.get(promise); if (existing) return existing; const normalized = normalizeError(error); this.dx.set(promise, normalized); return normalized; } cO(demand) { for (const participant of this.aq) { if (participant.demand === demand) { this.aq.delete(participant); } } this.b9(); } loadSubset(options, shouldReportError = () => true) { try { return this.collection._sync.loadSubset(options); } catch (error) { const normalized = normalizeError(error); if (shouldReportError()) this.bR(options, normalized); throw normalized; } } /** Create the record for a fresh, abortable acquisition attempt. */ cF(demand) { if (this.collection.config.syncMode !== `on-demand`) { return { options: demand.requestOptions, syncRunGeneration: this.collection._sync.getSyncRunGeneration() }; } 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 }, syncRunGeneration: this.collection._sync.getSyncRunGeneration(), abortController, removeRequestAbortListener }; } /** Retire an acquisition before user code; failed cleanup is not retryable. */ al(acquisition, reportReleaseError = this.bf === 0) { if (acquisition.releaseAttempted) return; acquisition.releaseAttempted = true; try { acquisition.abortController?.abort(); if (this.Z(acquisition.syncRunGeneration)) { this.collection._sync.unloadSubset(acquisition.options); } } catch (error) { const normalized = reportReleaseError ? this.bR( acquisition.options, normalizeError(error), true ) : normalizeError(error); throw normalized; } finally { acquisition.removeRequestAbortListener?.(); } } /** Start and retain the first acquisition for one logical subset demand. */ dD(requestOptions) { const demand = { requestOptions, acquisition: { options: requestOptions, syncRunGeneration: this.collection._sync.getSyncRunGeneration() }, 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.C.push(demand); const initialResult = createDeferred(); demand.initialResult = initialResult; const abort = () => initialResult.reject(new 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.cF(demand); demand.acquisition = acquisition; const replayState = this.o; const replayAttempt = replayState?.currentAttempt; const syncRunGeneration = this.collection._sync.getSyncRunGeneration(); this.C.push(demand); let result; try { result = this.loadSubset( acquisition.options, () => this.Z(syncRunGeneration) && this.C.includes(demand) && (replayState === void 0 || this.o === replayState && replayState.currentAttempt === replayAttempt) ); } catch (error) { const demandIndex = this.C.indexOf(demand); if (demandIndex !== -1) { if (replayState && replayAttempt && this.o === replayState && replayState.currentAttempt === replayAttempt) { replayState.failures.set(demand, normalizeError(error)); } this.C.splice(demandIndex, 1); } cancelAcquisition(acquisition); throw error; } if (!this.Z(syncRunGeneration)) { const demandIndex = this.C.indexOf(demand); if (demandIndex !== -1) this.C.splice(demandIndex, 1); cancelAcquisition(acquisition); return { demand, result, started: true }; } demand.acquisitionState = `active`; if (!this.C.includes(demand)) { this.al(acquisition); return { demand, result, started: true }; } if (replayState && replayAttempt) { this.cB( replayState, replayAttempt, demand, result ); } return { demand, result, started: true }; } /** Re-check ownership after adapter and event callbacks that may reenter. */ a4(demand) { return !this.unsubscribed && this.C.includes(demand); } bR(options, error, reportAborted = false) { const normalized = normalizeError(error); if (options.signal?.aborted && !reportAborted) return normalized; this.fu = normalized; this.bf++; try { this.emitInner(`loadSubset:error`, { type: `loadSubset:error`, subscription: this, options, error: normalized }); } finally { this.bf--; } return normalized; } emitEvents(changes) { if (this.unsubscribed) return false; const newChanges = this.d0(changes); if (changes.length > 0 && newChanges.length === 0) return false; if (this.eG(newChanges)) return false; return this.dN(newChanges); } /** * The route through which this subscription may receive only the changes * whose value or previous value holds the route's literal. A change reaches * the where filter only through those values, so the others cannot publish, * and sent-key records cover published rows only. Stale published rows and * truncate replay consume unfiltered changes, so no route applies then. */ get changeRoute() { if (this.ad.size > 0 || this.o !== void 0) { return void 0; } return this.equalityRoute; } /** Keep direct snapshot reads private while an authoritative replay is open. */ eH(changes) { if (!this.eG(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 = and(subWhereExp, snapshotWhereExp); stateOpts.where = combinedWhereExp; } else { stateOpts.where = snapshotWhereExp; } } } else { this.loadedInitialState = true; } const loadOptions = { ...opts?.refetch ? { refetch: true } : {}, 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 }; if (opts?.where) this.d8.set(loadOptions, opts.where); const { demand, result: syncResult, started } = this.dD(loadOptions); if (!this.a4(demand)) return false; opts?.onLoadSubsetResult?.( syncResult, demand.acquisition.options, (primaryFailure) => this.e6(demand, primaryFailure) ); if (!this.a4(demand)) return false; if (started) { this.co( syncResult, demand, demand.acquisition.options, opts?.trackLoadSubsetPromise ?? true ); } if (!this.a4(demand)) return false; let snapshot; if (opts?.onUnoptimized) { snapshot = this.collection.currentStateAsChanges({ ...stateOpts, optimizedOnly: true }); if (snapshot === void 0) { opts.onUnoptimized(); if (!this.a4(demand)) return false; snapshot = this.collection.currentStateAsChanges({ ...stateOpts, optimizedOnly: false }); } } else { snapshot = this.collection.currentStateAsChanges(stateOpts); } if (!this.a4(demand)) return false; if (snapshot === void 0) { return false; } const knownRows = this.o?.privateRows ?? this.ac; const filteredSnapshot = snapshot.filter( (change) => !this.fS && this.ad.has(change.key) || !this.aC.has(change.key) && !knownRows.has(change.key) ); for (const change of filteredSnapshot) { this.aC.add(change.key); } this.snapshotSent = true; this.eH( this.fS ? filteredSnapshot : this.cy(filteredSnapshot) ); return true; } /** Release one exact subset request while keeping the subscription alive. */ releaseSnapshot(where) { const index = this.C.findIndex( (demand) => demand.requestOptions.where === where || this.d8.get(demand.requestOptions) === where ); if (index === -1) return; this.d7(index); } e6(demand, primaryFailure) { if (!primaryFailure) { const index = this.C.indexOf(demand); if (index !== -1) this.d7(index); return; } try { this.bR( demand.acquisition.options, primaryFailure.error, true ); } finally { const index = this.C.indexOf(demand); if (index !== -1) this.d7(index, false); } } d7(index, reportReleaseError = this.bf === 0) { const demand = this.C[index]; if (!demand) return; const replayState = this.o; const acquisition = demand.acquisition; this.C.splice(index, 1); demand.initialResult?.reject(new LoadSubsetOperationAbortedError()); const releaseCallbacks = [ () => this.dX(demand), ...demand.acquisitionState === `active` ? [ // Adapter release is a supported reentrancy boundary. A demand // started from unload joins this replacement before completion. () => this.al(acquisition, reportReleaseError) ] : [], () => this.fm(), () => { if (replayState) this.bJ(replayState); }, // Ready follows replacement publication, never the delete half of it. () => this.cO(demand) ]; runAllCallbacks(releaseCallbacks); } /** A replay with no remaining logical demand cannot establish more rows. */ fm() { if (this.C.length !== 0 || !this.o) { return; } this.bP(); this.ad = new Map(this.ac); this.cg(); 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) => 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 use one minValue for the leading order term. For a * multi-column order, a separate equality request loads the complete * leading-value tie before local ordering applies the trailing terms. * * 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({ refetch, 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 ? 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 ? createFilterFunctionFromExpression(where) : void 0; const filterFn = (key) => { if (key !== void 0 && this.aC.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: eq(expression, new Value(minValueForIndex)) }); if (allRowsWithMinValue) { const keysWithMinValue = allRowsWithMinValue.map((change) => change.key).filter((key) => !this.aC.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` ? compileExpression( new PropRef(getPropRefPropertyPath(orderByExpression)), 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.aC.add(change.key); } this.eH(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 = buildCursorCurrent(orderBy, minValues); if (whereCurrentCursor) { cursorExpressions = { whereFrom: whereFromCursor, whereCurrent: whereCurrentCursor, lastKey: this.lastSentKey }; } } const loadOptions = { ...refetch ? { refetch: true } : {}, 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.dD(loadOptions); if (!this.a4(demand)) return; onLoadSubsetResult?.( syncResult, demand.acquisition.options, (primaryFailure) => this.e6(demand, primaryFailure) ); if (!this.a4(demand)) return; if (started) { this.co( syncResult, demand, demand.acquisition.options, shouldTrackLoadSubsetPromise ); } if (!this.a4(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. */ d0(changes) { changes = this.cy(changes); if (this.loadedInitialState || this.ej) { return changes; } const skipDeleteFilter = this.fS; const insertedInBatch = /* @__PURE__ */ new Set(); const newChanges = []; for (const change of changes) { let newChange = change; const keyInSentKeys = this.aC.has(change.key) || insertedInBatch.has(change.key); if (!keyInSentKeys) { if (change.type === `update`) { newChange = { ...change, type: `insert`, previousValue: void 0 }; insertedInBatch.add(change.key); } else if (change.type === `delete`) { if (!skipDeleteFilter) { continue; } } else { insertedInBatch.add(change.key); } } else { if (change.type === `insert`) { continue; } else if (change.type === `delete`) { this.aC.delete(change.key); insertedInBatch.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. */ cy(changes) { if (this.ad.size === 0) return changes; const reconciled = []; for (const change of changes) { const previous = this.ad.get(change.key); if (previous === void 0) { reconciled.push(change); continue; } this.ad.delete(change.key); if (change.type === `delete`) { reconciled.push({ ...change, value: previous, previousValue: void 0 }); } else if (!deepEquals(previous, change.value)) { reconciled.push({ ...change, type: `update`, previousValue: previous }); } } if (this.collection.config.syncMode !== `on-demand` && !this.fS) { for (const [key, value] of this.ad) { if (this.collection.has(key)) continue; this.ad.delete(key); reconciled.push({ type: `delete`, key, value }); } } return reconciled; } fj(changes) { for (const change of changes) { if (change.type === `delete`) { this.ac.delete(change.key); } else { this.ac.set(change.key, change.value); } } } e9(changes) { if (this.loadedInitialState || this.ej) { return; } for (const change of changes) { if (change.type === `delete`) { this.aC.delete(change.key); } else { this.aC.add(change.key); } } if (this.orderByIndex) { this.limitedSnapshotRowCount = Math.max( this.limitedSnapshotRowCount, this.aC.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.ej = true; } unsubscribe() { if (this.unsubscribed) return; this.unsubscribed = true; this.aV++; const sourceListenerCleanups = [ this.ec, this.dB, this.cb ]; this.ec = void 0; this.dB = void 0; this.cb = void 0; runAllCallbacks([ ...sourceListenerCleanups.map((cleanup) => () => cleanup?.()), () => { this.bP(); this.ad.clear(); const acquisitions = this.C.filter((demand) => demand.acquisitionState === `active`).map((demand) => demand.acquisition); for (const demand of this.C) { demand.initialResult?.reject(new LoadSubsetOperationAbortedError()); this.cO(demand); if (demand.acquisitionState === `starting`) { cancelAcquisition(demand.acquisition); } } this.C = []; runAllCallbacks( acquisitions.map( (acquisition) => () => this.al(acquisition) ) ); }, () => this.emitInner(`unsubscribed`, { type: `unsubscribed`, subscription: this }), // Clear all event listeners to prevent memory leaks () => this.clearListeners() ]); } } export { CollectionSubscription }; //# sourceMappingURL=subscription.js.map