UNPKG

@tanstack/db

Version:

A reactive client store for building super fast apps on sync

473 lines (420 loc) 17.2 kB
import { normalizeExpressionPaths } from '../compiler/expressions.js' import { OrderedSourceLoader } from './ordered-source-loader.js' import { computeSubscriptionOrderByHints, reconcileChangesForD2, sendChangesToInput, splitUpdates, } from './utils.js' import { SubsetDemandController } from './subset-demand-controller.js' import type { Collection } from '../../collection/index.js' import type { ChangeMessage, LoadSubsetRequestResult, SubscribeChangesOptions, SubscriptionLoadSubsetErrorEvent, SubscriptionStatusChangeEvent, } from '../../types.js' import type { Context, GetResult } from '../builder/types.js' import type { BasicExpression } from '../ir.js' import type { OrderByOptimizationInfo } from '../compiler/order-by.js' import type { CollectionConfigBuilder } from './collection-config-builder.js' import type { CollectionSubscription } from '../../collection/subscription.js' import type { LazyDemandPlan } from '../compiler/joins.js' const loadMoreCallbackSymbol = Symbol.for( `@tanstack/db.collection-config-builder`, ) type TruncateReplayPublicationControl = NonNullable< SubscribeChangesOptions[`truncateReplayPublication`] > export class CollectionSubscriber< TContext extends Context, TResult extends object = GetResult<TContext>, > { // Track deferred promises for subscription loading states private subscriptionLoadingPromises = new Map< CollectionSubscription, { resolve: () => void } >() // Exact row last contributed to D2 for each source key. private sentToD2Rows = new Map<string | number, Record<string, unknown>>() // Direct load tracking callback for ordered path (set during subscribeToOrderedChanges, // used by loadNextItems for subsequent requestLimitedSnapshot calls) private orderedLoader: OrderedSourceLoader | undefined private readonly demand = new SubsetDemandController() constructor( private sourceId: string, private alias: string, private collection: Collection, private collectionConfigBuilder: CollectionConfigBuilder<TContext, TResult>, ) {} subscribe(): CollectionSubscription { const whereClause = this.getWhereClause() if (whereClause) { const whereExpression = normalizeExpressionPaths(whereClause, this.alias) return this.subscribeToChanges(whereExpression) } return this.subscribeToChanges() } private subscribeToChanges(whereExpression?: BasicExpression<boolean>) { const orderByInfo = this.getOrderByInfo() // Direct load promise tracking: pipes loadSubset results straight to the // live query collection, avoiding the multi-hop deferred promise chain that // can break under microtask timing (e.g., queueMicrotask in TanStack Query). const trackLoadResult = (result: LoadSubsetRequestResult) => { if (result instanceof Promise) { // Defer the tracked rejection by one microtask so the subscription's // error event can put an initial live query in error before loading // state would otherwise let it become ready. const trackedResult = result.catch(async (error: unknown) => { await Promise.resolve() throw error }) this.collectionConfigBuilder.trackSubsetLoadPromise(trackedResult) } } // Status change handler - passed to subscribeChanges so it's registered // BEFORE any snapshot is requested, preventing race conditions. // Used as a fallback for status transitions not covered by direct tracking // (e.g., truncate-triggered reloads that call trackLoadSubsetPromise directly). const onStatusChange = (event: SubscriptionStatusChangeEvent) => { if (this.collectionConfigBuilder.isLazySource(this.sourceId)) return const subscription = event.subscription as CollectionSubscription if (event.status === `loadingSubset`) { this.ensureLoadingPromise(subscription) } else { // status is 'ready' const deferred = this.subscriptionLoadingPromises.get(subscription) if (deferred) { this.subscriptionLoadingPromises.delete(subscription) deferred.resolve() } } } const onLoadSubsetError = (event: SubscriptionLoadSubsetErrorEvent) => { this.collectionConfigBuilder.recordSubsetError( event.error, // Lazy demand owns its fatal-error path. For eager sources, one // successful page does not finish initial ordered refinement. !this.collectionConfigBuilder.isLazySource(this.sourceId) && this.collectionConfigBuilder.liveQueryCollection?.status === `loading`, ) } // Create subscription with onStatusChange - listener is registered before any async work let subscription: CollectionSubscription if (orderByInfo) { subscription = this.subscribeToOrderedChanges( whereExpression, orderByInfo, onStatusChange, trackLoadResult, onLoadSubsetError, ) } else { // Lazy sources load only the subsets demanded by the compiled graph. const includeInitialState = (this.collection.config.syncMode !== `on-demand` || this.collectionConfigBuilder.query.limit !== 0) && !this.collectionConfigBuilder.isLazySource(this.sourceId) subscription = this.subscribeToMatchingChanges( whereExpression, includeInitialState, onStatusChange, trackLoadResult, onLoadSubsetError, ) this.registerSubscriptionCleanup(subscription) } // Check current status after subscribing - if status is 'loadingSubset', track it. // The onStatusChange listener will catch the transition to 'ready'. if ( !this.collectionConfigBuilder.isLazySource(this.sourceId) && subscription.status === `loadingSubset` ) { this.ensureLoadingPromise(subscription) } return subscription } private registerSubscriptionCleanup( subscription: CollectionSubscription, ): void { const unsubscribe = () => { // If subscription has a pending promise, resolve it before unsubscribing const deferred = this.subscriptionLoadingPromises.get(subscription) if (deferred) { this.subscriptionLoadingPromises.delete(subscription) deferred.resolve() } try { this.demand.clear() } finally { subscription.unsubscribe() } } // currentSyncState is always defined when subscribe() is called // (called during sync session setup) this.collectionConfigBuilder.currentSyncState!.unsubscribeCallbacks.add( unsubscribe, ) } setDemand( subscription: CollectionSubscription, plan: LazyDemandPlan, keys: Set<unknown>, ): void { let update try { update = this.demand.setDemand(subscription, plan, keys) } catch (error) { // CollectionSubscription reports adapter failures before rethrowing. // Convert that synchronous form to the same query-local fatal demand // state as a rejected load, without letting it escape the source commit. // Preserve unrelated graph/programming errors as throws. if (!Object.is(subscription.lastError, error)) throw error const isInitialSync = this.collectionConfigBuilder.liveQueryCollection?.status === `loading` const generation = this.collectionConfigBuilder.beginDemand(plan.id) this.collectionConfigBuilder.failDemand(plan.id, generation, error) if (isInitialSync) throw error return } if (!update.changed) return if (update.empty) { this.collectionConfigBuilder.retireDemand(plan.id) return } const generation = this.collectionConfigBuilder.beginDemand(plan.id) if (update.ready instanceof Promise) { this.collectionConfigBuilder.trackSubsetLoadOperationPromise(update.ready) void update.ready.then( () => this.collectionConfigBuilder.settleDemand(plan.id, generation), (error) => this.collectionConfigBuilder.failDemand(plan.id, generation, error), ) } else { this.collectionConfigBuilder.settleDemand(plan.id, generation) } } private sendChangesToPipeline( changes: Iterable<ChangeMessage<any, string | number>>, callback?: () => void, ) { const changesArray = Array.isArray(changes) ? changes : [...changes] const reconciledChanges = reconcileChangesForD2( changesArray, this.sentToD2Rows, ) // currentSyncState and input are always defined when this method is called // (only called from active subscriptions during a sync session) const input = this.collectionConfigBuilder.currentSyncState!.inputs[this.sourceId]! const sentChanges = sendChangesToInput(input, reconciledChanges) // Do not provide the callback that loads more data // if there's no more data to load // otherwise we end up in an infinite loop trying to load more data const dataLoader = sentChanges > 0 ? callback : undefined // We need to schedule a graph run even if there's no data to load // because we need to mark the collection as ready if it's not already // and that's only done in `scheduleGraphRun` this.collectionConfigBuilder.scheduleGraphRun(dataLoader) } private subscribeToMatchingChanges( whereExpression: BasicExpression<boolean> | undefined, includeInitialState: boolean, onStatusChange: (event: SubscriptionStatusChangeEvent) => void, onLoadSubsetResult: (result: LoadSubsetRequestResult) => void, onLoadSubsetError: (event: SubscriptionLoadSubsetErrorEvent) => void, ): CollectionSubscription { const sendChanges = ( changes: Array<ChangeMessage<any, string | number>>, ) => { this.sendChangesToPipeline(changes) } // Get the query's orderBy and limit to pass to loadSubset. const hints = computeSubscriptionOrderByHints( this.collectionConfigBuilder.query, this.alias, ) // Track loading via the loadSubset promise directly. // requestSnapshot uses trackLoadSubsetPromise: false (needed for truncate handling), // so we use onLoadSubsetResult to get the promise and track it ourselves. const subscription = this.collection.subscribeChanges(sendChanges, { ...(includeInitialState && { includeInitialState }), whereExpression, onStatusChange, onLoadSubsetError, truncateReplayPublication: this.truncateReplayPublicationControl(), orderBy: hints.orderBy, limit: hints.limit, onLoadSubsetResult: includeInitialState ? onLoadSubsetResult : undefined, }) return subscription } private subscribeToOrderedChanges( whereExpression: BasicExpression<boolean> | undefined, orderByInfo: OrderByOptimizationInfo, onStatusChange: (event: SubscriptionStatusChangeEvent) => void, onLoadSubsetResult: (result: LoadSubsetRequestResult) => void, onLoadSubsetError: (event: SubscriptionLoadSubsetErrorEvent) => void, ): CollectionSubscription { // Use a holder to forward-reference subscription in the callback const subscriptionHolder: { current?: CollectionSubscription } = {} const sendChangesInRange = ( changes: Iterable<ChangeMessage<any, string | number>>, ) => { const subscription = subscriptionHolder.current if (!subscription) return const changesArray = Array.isArray(changes) ? changes : [...changes] this.orderedLoader?.onSourceChanges(changesArray, this.sentToD2Rows) // Split live updates into a delete of the old value and an insert of the new value const splittedChanges = splitUpdates(changesArray) this.sendChangesToPipelineWithTracking(splittedChanges, subscription) } // Subscribe to changes with onStatusChange - listener is registered before any snapshot // values bigger than what we've sent don't need to be sent because they can't affect the topK const subscription = this.collection.subscribeChanges(sendChangesInRange, { whereExpression, onStatusChange, onLoadSubsetError, truncateReplayPublication: this.truncateReplayPublicationControl(() => { // Recovery favors a simple, authoritative rebuild over resuming a // fragile cursor. The retained full-source demand is replayed on later // truncates, so this adds at most one demand per subscription. // Queue startup inside the publication barrier too: a synchronous // throw establishes no acquisition for the replay to wait on. const loader = this.orderedLoader this.collectionConfigBuilder.trackOrderedLoadPromise( Promise.resolve().then(() => loader?.loadFullSource()), true, ) }), }) subscriptionHolder.current = subscription this.registerSubscriptionCleanup(subscription) // Reset ordered-load state on truncate. Keep exact D2 rows until the // replacement publication retracts or replaces them. const truncateUnsubscribe = this.collection.on(`truncate`, () => { this.orderedLoader?.resetCursor() }) // Clean up truncate listener when subscription is unsubscribed subscription.on(`unsubscribed`, () => { truncateUnsubscribe() subscriptionHolder.current = undefined this.orderedLoader?.dispose() this.orderedLoader = undefined }) this.orderedLoader = new OrderedSourceLoader( orderByInfo, subscription, this.alias, (result, holdPublication) => { if (result instanceof Promise) { this.collectionConfigBuilder.trackOrderedLoadPromise( result, holdPublication && !subscription.hasPendingTruncateReplacement, ) } onLoadSubsetResult(result) }, ) this.orderedLoader.start() return subscription } private truncateReplayPublicationControl( onStart?: () => void, ): TruncateReplayPublicationControl { const syncSession = this.collectionConfigBuilder.getSyncSession() return { start: () => { onStart?.() }, succeed: () => { if (syncSession !== this.collectionConfigBuilder.getSyncSession()) { return } this.orderedLoader?.settleFullSourceReplay() this.collectionConfigBuilder.scheduleGraphRunForSession(syncSession) }, } } // This function is called by maybeRunGraph // after each iteration of the query pipeline // to ensure that the orderBy operator has enough data to work with loadMoreIfNeeded(subscription: CollectionSubscription): void { if ( subscription.hasPendingTruncateReplacement && !this.collectionConfigBuilder.hasActiveWindowOperation() ) { return } const orderByInfo = this.getOrderByInfo() if (!orderByInfo) { // This query has no orderBy operator // so there's no data to load return } try { const pending = this.orderedLoader?.loadMore( this.collectionConfigBuilder.getActiveWindowOperationGeneration(), ) if (pending) { this.collectionConfigBuilder.trackSubsetLoadOperationPromise(pending) } } catch (error) { if (!Object.is(subscription.lastError, error)) throw error } } private sendChangesToPipelineWithTracking( changes: Iterable<ChangeMessage<any, string | number>>, subscription: CollectionSubscription, ) { const orderByInfo = this.getOrderByInfo() if (!orderByInfo) { this.sendChangesToPipeline(changes) return } // Cache the loadMoreIfNeeded callback on the subscription using a symbol property. // This ensures we pass the same function instance to the scheduler each time, // allowing it to deduplicate callbacks when multiple changes arrive during a transaction. type SubscriptionWithLoader = CollectionSubscription & { [loadMoreCallbackSymbol]?: () => void } const subscriptionWithLoader = subscription as SubscriptionWithLoader subscriptionWithLoader[loadMoreCallbackSymbol] ??= this.loadMoreIfNeeded.bind(this, subscription) this.sendChangesToPipeline( changes, subscriptionWithLoader[loadMoreCallbackSymbol], ) } private getWhereClause(): BasicExpression<boolean> | undefined { const sourceWhereClausesCache = this.collectionConfigBuilder.sourceWhereClausesCache if (!sourceWhereClausesCache) { return undefined } return sourceWhereClausesCache.get(this.sourceId) } private getOrderByInfo(): OrderByOptimizationInfo | undefined { const info = this.collectionConfigBuilder.optimizableOrderByCollections[this.sourceId] if (info?.sourceId === this.sourceId) { return info } return undefined } private ensureLoadingPromise(subscription: CollectionSubscription) { if (this.subscriptionLoadingPromises.has(subscription)) { return } let resolve: () => void const promise = new Promise<void>((res) => { resolve = res }) this.subscriptionLoadingPromises.set(subscription, { resolve: resolve!, }) this.collectionConfigBuilder.trackSubsetLoadPromise(promise) } }