UNPKG

@tanstack/db

Version:

A reactive client store for building super fast apps on sync

318 lines (317 loc) • 10.5 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"; import { readRouteValue } from "./change-events.js"; class CollectionChangesManager { /** * Creates a new CollectionChangesManager instance */ constructor() { this.activeSubscribersCount = 0; this.changeSubscriptions = /* @__PURE__ */ new Set(); this.batchedEvents = []; this.shouldBatchEvents = false; this.cM = /* @__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 ($hasPendingWrites, $synced, $origin, $key, $collectionId). * Uses the "add-if-missing" pattern to preserve virtual properties from upstream collections. */ cK(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. eW(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.cK(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.eW(combined.get(change.key), change) ); } rawEvents = [...combined.values()].flatMap((change) => { return change ? [change] : []; }); } this.batchedEvents = []; this.shouldBatchEvents = false; } if (this.aN) { this.aN.publications.push({ changes: rawEvents, layoutChanged }); return; } this.e5(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.aN ??= { depth: 0, discard: false, stateRevision: this.stateRevision, layoutRevision: this.layoutRevision, publications: [] }; deferral.depth++; let closed = false; const close = (discard) => { if (closed || this.aN !== deferral) return; closed = true; deferral.discard ||= discard; if (--deferral.depth > 0) return; const publications = deferral.publications; deferral.publications = []; this.aN = void 0; if (deferral.discard) { this.stateRevision = deferral.stateRevision; this.layoutRevision = deferral.layoutRevision; return; } this.e5( publications.flatMap(({ changes }) => changes), publications.some(({ layoutChanged }) => layoutChanged) ); }; return { publish: () => close(false), discard: () => close(true) }; } e5(rawEvents, layoutChanged) { if (rawEvents.length === 0 && !layoutChanged) { return; } const enrichedEvents = rawEvents.map((change) => this.cK(change)); const layoutListeners = [...this.cM]; const subscriptions = [...this.changeSubscriptions]; withPublicationContext(() => { const routed = rawEvents.length > 0 ? routeChanges(enrichedEvents, subscriptions) : void 0; const callbacks = []; for (const subscription of subscriptions) { const own = routed?.get(subscription); callbacks.push(() => { if (own === void 0 || !subscription.changeRoute) { subscription.emitEvents(enrichedEvents); } else if (own.length > 0) { subscription.emitEvents(own); } }); } 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.cM.add(listener); return () => this.cM.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.ei(); let subscription; const setupState = { closed: false }; try { subscription = new CollectionSubscription(this.collection, callback, { ...opts, whereExpression, onUnsubscribe: () => { setupState.closed = true; this.eB(); 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.eB(); } throw error; } return subscription; } /** * Increment the active subscribers count and start sync if needed */ ei() { 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 */ eB() { 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.aN) this.aN.publications.length = 0; this.aN = void 0; } } function routeChanges(changes, subscriptions) { const routes = subscriptions.map((subscription) => subscription.changeRoute); if (routes.every((route) => route === void 0)) return void 0; const routed = /* @__PURE__ */ new Map(); const groups = /* @__PURE__ */ new Map(); for (const [index, subscription] of subscriptions.entries()) { const route = routes[index]; if (!route) continue; routed.set(subscription, []); let group = groups.get(route.pathKey); if (!group) { group = { path: route.path, byLiteral: /* @__PURE__ */ new Map() }; groups.set(route.pathKey, group); } const peers = group.byLiteral.get(route.expected); if (peers) peers.push(subscription); else group.byLiteral.set(route.expected, [subscription]); } const deliver = (targets, change) => { for (const subscription of targets ?? []) { routed.get(subscription).push(change); } }; for (const change of changes) { for (const group of groups.values()) { const value = readRouteValue(change.value, group.path); const previous = change.previousValue === void 0 ? void 0 : readRouteValue(change.previousValue, group.path); deliver(group.byLiteral.get(value), change); if (previous !== value) deliver(group.byLiteral.get(previous), change); } } return routed; } export { CollectionChangesManager }; //# sourceMappingURL=changes.js.map