@tanstack/db
Version:
A reactive client store for building super fast apps on sync
206 lines (180 loc) • 6.59 kB
text/typescript
import { NegativeActiveSubscribersError } from '../errors'
import {
createSingleRowRefProxy,
toExpression,
} from '../query/builder/ref-proxy.js'
import { CollectionSubscription } from './subscription.js'
import type { StandardSchemaV1 } from '@standard-schema/spec'
import type { ChangeMessage, SubscribeChangesOptions } from '../types'
import type { CollectionLifecycleManager } from './lifecycle.js'
import type { CollectionSyncManager } from './sync.js'
import type { CollectionEventsManager } from './events.js'
import type { CollectionImpl } from './index.js'
export class CollectionChangesManager<
TOutput extends object = Record<string, unknown>,
TKey extends string | number = string | number,
TSchema extends StandardSchemaV1 = StandardSchemaV1,
TInput extends object = TOutput,
> {
private lifecycle!: CollectionLifecycleManager<TOutput, TKey, TSchema, TInput>
private sync!: CollectionSyncManager<TOutput, TKey, TSchema, TInput>
private events!: CollectionEventsManager
private collection!: CollectionImpl<TOutput, TKey, any, TSchema, TInput>
public activeSubscribersCount = 0
public changeSubscriptions = new Set<CollectionSubscription>()
public batchedEvents: Array<ChangeMessage<TOutput, TKey>> = []
public shouldBatchEvents = false
/**
* Creates a new CollectionChangesManager instance
*/
constructor() {}
public setDeps(deps: {
lifecycle: CollectionLifecycleManager<TOutput, TKey, TSchema, TInput>
sync: CollectionSyncManager<TOutput, TKey, TSchema, TInput>
events: CollectionEventsManager
collection: CollectionImpl<TOutput, TKey, any, TSchema, TInput>
}) {
this.lifecycle = deps.lifecycle
this.sync = deps.sync
this.events = deps.events
this.collection = deps.collection
}
/**
* Emit an empty ready event to notify subscribers that the collection is ready
* This bypasses the normal empty array check in emitEvents
*/
public emitEmptyReadyEvent(): void {
// Emit empty array directly to all subscribers
for (const subscription of this.changeSubscriptions) {
subscription.emitEvents([])
}
}
/**
* Emit events either immediately or batch them for later emission
*/
public emitEvents(
changes: Array<ChangeMessage<TOutput, TKey>>,
forceEmit = false,
): void {
// Skip batching for user actions (forceEmit=true) to keep UI responsive
if (this.shouldBatchEvents && !forceEmit) {
// Add events to the batch
this.batchedEvents.push(...changes)
return
}
// Either we're not batching, or we're forcing emission (user action or ending batch cycle)
let eventsToEmit = changes
if (forceEmit) {
// Force emit is used to end a batch (e.g. after a sync commit). Combine any
// buffered optimistic events with the final changes so subscribers see the
// whole picture, even if the sync diff is empty.
if (this.batchedEvents.length > 0) {
eventsToEmit = [...this.batchedEvents, ...changes]
}
this.batchedEvents = []
this.shouldBatchEvents = false
}
if (eventsToEmit.length === 0) {
return
}
// Emit to all listeners
for (const subscription of this.changeSubscriptions) {
subscription.emitEvents(eventsToEmit)
}
}
/**
* Subscribe to changes in the collection
*/
public subscribeChanges(
callback: (changes: Array<ChangeMessage<TOutput>>) => void,
options: SubscribeChangesOptions<TOutput> = {},
): CollectionSubscription {
// Start sync and track subscriber
this.addSubscriber()
// Compile where callback to whereExpression if provided
if (options.where && options.whereExpression) {
throw new Error(
`Cannot specify both 'where' and 'whereExpression' options. Use one or the other.`,
)
}
const { where, ...opts } = options
let whereExpression = opts.whereExpression
if (where) {
const proxy = createSingleRowRefProxy<TOutput>()
const result = where(proxy)
whereExpression = toExpression(result)
}
const subscription = new CollectionSubscription(this.collection, callback, {
...opts,
whereExpression,
onUnsubscribe: () => {
this.removeSubscriber()
this.changeSubscriptions.delete(subscription)
},
})
// Register status listener BEFORE requesting snapshot to avoid race condition.
// This ensures the listener catches all status transitions, even if the
// loadSubset promise resolves synchronously or very quickly.
if (options.onStatusChange) {
subscription.on(`status:change`, options.onStatusChange)
}
if (options.includeInitialState) {
subscription.requestSnapshot({
trackLoadSubsetPromise: false,
orderBy: options.orderBy,
limit: options.limit,
onLoadSubsetResult: options.onLoadSubsetResult,
})
} else if (options.includeInitialState === false) {
// When explicitly set to false (not just undefined), mark all state as "seen"
// so that all future changes (including deletes) pass through unfiltered.
subscription.markAllStateAsSeen()
}
// Add to batched listeners
this.changeSubscriptions.add(subscription)
return subscription
}
/**
* Increment the active subscribers count and start sync if needed
*/
private addSubscriber(): void {
const previousSubscriberCount = this.activeSubscribersCount
this.activeSubscribersCount++
this.lifecycle.cancelGCTimer()
// Start sync if collection was cleaned up
if (
this.lifecycle.status === `cleaned-up` ||
this.lifecycle.status === `idle`
) {
this.sync.startSync()
}
this.events.emitSubscribersChange(
this.activeSubscribersCount,
previousSubscriberCount,
)
}
/**
* Decrement the active subscribers count and start GC timer if needed
*/
private removeSubscriber(): void {
const previousSubscriberCount = this.activeSubscribersCount
this.activeSubscribersCount--
if (this.activeSubscribersCount === 0) {
this.lifecycle.startGCTimer()
} else if (this.activeSubscribersCount < 0) {
throw new NegativeActiveSubscribersError()
}
this.events.emitSubscribersChange(
this.activeSubscribersCount,
previousSubscriberCount,
)
}
/**
* Clean up the collection by stopping sync and clearing data
* This can be called manually or automatically by garbage collection
*/
public cleanup(): void {
this.batchedEvents = []
this.shouldBatchEvents = false
}
}