UNPKG

@tanstack/db

Version:

A reactive client store for building super fast apps on sync

275 lines (274 loc) • 9.05 kB
import { NegativeActiveSubscribersError } from "../errors.js"; import { withPublicationContext, recordPublicationError } from "../scheduler.js"; import { runAllCallbacks } from "../utils/callbacks.js"; import { createSingleRowRefProxy, toExpression } from "../query/builder/ref-proxy.js"; import { CollectionSubscription } from "./subscription.js"; class CollectionChangesManager { /** * Creates a new CollectionChangesManager instance */ constructor() { this.activeSubscribersCount = 0; this.changeSubscriptions = /* @__PURE__ */ new Set(); this.batchedEvents = []; this.shouldBatchEvents = false; this.layoutChangeListeners = /* @__PURE__ */ new Set(); this.stateRevision = 0; this.layoutRevision = 0; } setDeps(deps) { this.lifecycle = deps.lifecycle; this.sync = deps.sync; this.events = deps.events; this.collection = deps.collection; this.state = deps.state; } /** * Emit an empty ready event to notify subscribers that the collection is ready * This bypasses the normal empty array check in emitEvents */ emitEmptyReadyEvent() { withPublicationContext(() => { try { runAllCallbacks( [...this.changeSubscriptions].map( (subscription) => () => subscription.emitEvents([]) ) ); } catch (error) { recordPublicationError(error); } }); } /** * Enriches a change message with virtual properties ($synced, $origin, $key, $collectionId). * Uses the "add-if-missing" pattern to preserve virtual properties from upstream collections. */ enrichChangeWithVirtualProps(change) { return this.state.enrichChangeMessage(change); } // Reduce unpublished same-key changes relative to the subscriber's last // visible row: keep the earliest previous row and latest value, and cancel // an insert followed by a delete. composeBatchedChange(pending, change) { if (!pending) return change; if (pending.type === `insert`) { if (change.type === `delete`) return void 0; return { ...change, type: `insert`, previousValue: void 0 }; } const previousValue = pending.type === `update` ? pending.previousValue ?? pending.value : pending.value; if (change.type === `delete`) { return { ...change, value: previousValue, previousValue: void 0 }; } return { ...change, type: `update`, previousValue }; } /** * Emit events either immediately or batch them for later emission */ emitEvents(changes, forceEmit = false, layoutChanged = false) { if (changes.length > 0) this.stateRevision++; if (layoutChanged) this.layoutRevision++; if (this.shouldBatchEvents && !forceEmit) { this.batchedEvents.push( ...changes.map((change) => this.enrichChangeWithVirtualProps(change)) ); return; } let rawEvents = changes; if (forceEmit) { if (this.batchedEvents.length > 0) { const combined = /* @__PURE__ */ new Map(); for (const change of [...this.batchedEvents, ...changes]) { combined.set( change.key, this.composeBatchedChange(combined.get(change.key), change) ); } rawEvents = [...combined.values()].flatMap((change) => { return change ? [change] : []; }); } this.batchedEvents = []; this.shouldBatchEvents = false; } if (this.deferral) { this.deferral.publications.push({ changes: rawEvents, layoutChanged }); return; } this.publishEvents(rawEvents, layoutChanged); } /** * Defers subscriber delivery while a coherent multi-Collection publication * installs all of its visible state. State and indexes still commit at their * normal transaction boundaries. */ deferPublication() { const deferral = this.deferral ??= { depth: 0, discard: false, stateRevision: this.stateRevision, layoutRevision: this.layoutRevision, publications: [] }; deferral.depth++; let closed = false; const close = (discard) => { if (closed || this.deferral !== deferral) return; closed = true; deferral.discard ||= discard; if (--deferral.depth > 0) return; const publications = deferral.publications; deferral.publications = []; this.deferral = void 0; if (deferral.discard) { this.stateRevision = deferral.stateRevision; this.layoutRevision = deferral.layoutRevision; return; } this.publishEvents( publications.flatMap(({ changes }) => changes), publications.some(({ layoutChanged }) => layoutChanged) ); }; return { publish: () => close(false), discard: () => close(true) }; } publishEvents(rawEvents, layoutChanged) { if (rawEvents.length === 0 && !layoutChanged) { return; } const enrichedEvents = rawEvents.map((change) => this.enrichChangeWithVirtualProps(change)); const layoutListeners = [...this.layoutChangeListeners]; const subscriptions = [...this.changeSubscriptions]; withPublicationContext(() => { const callbacks = subscriptions.map( (subscription) => () => subscription.emitEvents(enrichedEvents) ); if (rawEvents.length === 0) { callbacks.unshift(...layoutListeners); } try { runAllCallbacks(callbacks); } catch (error) { recordPublicationError(error); } }); } /** Subscribe to layout-only publications. Internal observer channel. */ subscribeLayoutChanges(listener) { this.layoutChangeListeners.add(listener); return () => this.layoutChangeListeners.delete(listener); } /** * Subscribe to changes in the collection */ subscribeChanges(callback, options = {}) { 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(); const result = where(proxy); whereExpression = toExpression(result); } this.addSubscriber(); let subscription; const setupState = { closed: false }; try { subscription = new CollectionSubscription(this.collection, callback, { ...opts, whereExpression, onUnsubscribe: () => { setupState.closed = true; this.removeSubscriber(); if (subscription) this.changeSubscriptions.delete(subscription); } }); 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) { subscription.markAllStateAsSeen(); } if (!setupState.closed) this.changeSubscriptions.add(subscription); } catch (error) { if (subscription) { try { subscription.unsubscribe(); } catch { } } else { this.removeSubscriber(); } throw error; } return subscription; } /** * Increment the active subscribers count and start sync if needed */ addSubscriber() { const previousSubscriberCount = this.activeSubscribersCount; this.activeSubscribersCount++; this.lifecycle.cancelGCTimer(); try { if (this.lifecycle.status === `cleaned-up` || this.lifecycle.status === `idle`) { this.sync.startSync(); } } catch (error) { this.activeSubscribersCount = previousSubscriberCount; if (this.activeSubscribersCount === 0) { this.lifecycle.startGCTimer(); } throw error; } this.events.emitSubscribersChange( this.activeSubscribersCount, previousSubscriberCount ); } /** * Decrement the active subscribers count and start GC timer if needed */ removeSubscriber() { 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 */ cleanup() { this.stateRevision++; this.batchedEvents = []; this.shouldBatchEvents = false; if (this.deferral) this.deferral.publications.length = 0; this.deferral = void 0; } } export { CollectionChangesManager }; //# sourceMappingURL=changes.js.map