UNPKG

@tanstack/db

Version:

A reactive client store for building super fast apps on sync

650 lines (649 loc) • 22.3 kB
"use strict"; Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" }); const dbIvm = require("@tanstack/db-ivm"); const deferred = require("../deferred.cjs"); const callbacks = require("../utils/callbacks.cjs"); const error = require("../utils/error.cjs"); const utils$1 = require("../utils.cjs"); const index = require("./compiler/index.cjs"); const expressions = require("./compiler/expressions.cjs"); const collectionRegistry = require("./live/collection-registry.cjs"); const graphScheduler = require("./live/graph-scheduler.cjs"); const subsetDemandController = require("./live/subset-demand-controller.cjs"); const orderedSourceLoader = require("./live/ordered-source-loader.cjs"); const utils = require("./live/utils.cjs"); const ir = require("./ir.cjs"); let effectCounter = 0; function createEffect(config) { const id = config.id ?? `live-query-effect-${++effectCounter}`; const abortController = new AbortController(); const ctx = { effectId: id, signal: abortController.signal }; const inFlightHandlers = /* @__PURE__ */ new Set(); let disposed = false; const onBatchProcessed = (events) => { if (disposed) return; if (events.length === 0) return; if (config.onBatch) { try { const result = config.onBatch(events, ctx); if (result instanceof Promise) { const tracked = result.catch((error2) => { reportError(error2, events[0], config.onError); }); trackPromise(tracked, inFlightHandlers); } } catch (error2) { reportError(error2, events[0], config.onError); } } for (const event of events) { if (abortController.signal.aborted) break; const handler = getHandlerForEvent(event, config); if (!handler) continue; try { const result = handler(event, ctx); if (result instanceof Promise) { const tracked = result.catch((error2) => { reportError(error2, event, config.onError); }); trackPromise(tracked, inFlightHandlers); } } catch (error2) { reportError(error2, event, config.onError); } } }; let disposalPromise; const dispose = () => { if (disposalPromise) return disposalPromise; const completion = deferred.createDeferred(); const attempt = completion.promise; disposalPromise = attempt; disposed = true; abortController.abort(); void (async () => { let cleanupFailed = false; let cleanupError; try { runner.dispose(); } catch (error2) { cleanupFailed = true; cleanupError = error2; } if (inFlightHandlers.size > 0) { await Promise.allSettled([...inFlightHandlers]); } if (cleanupFailed) throw cleanupError; })().then(completion.resolve, completion.reject); void attempt.then( () => { }, () => { if (disposalPromise === attempt) disposalPromise = void 0; } ); return attempt; }; const runner = new EffectPipelineRunner({ query: config.query, skipInitial: config.skipInitial ?? false, onBatchProcessed, onSourceError: (error2) => { if (disposed) return; if (config.onSourceError) { try { config.onSourceError(error2); } catch (callbackError) { console.error( `[Effect '${id}'] onSourceError callback threw:`, callbackError ); } } else { console.error(`[Effect '${id}'] ${error2.message}. Disposing effect.`); } void dispose().catch((cleanupError) => { console.error( `[Effect '${id}'] failed to dispose after a source error:`, cleanupError ); }); } }); try { runner.start(); } catch (error2) { try { runner.dispose(); } catch (cleanupError) { console.error( `[Effect '${id}'] failed to dispose after a startup error:`, cleanupError ); } throw error2; } return { dispose, get disposed() { return disposed; } }; } class EffectPipelineRunner { constructor(config) { this.subscriptions = {}; this.lazySourcesCallbacks = {}; this.lazySources = /* @__PURE__ */ new Set(); this.demand = new subsetDemandController.SubsetDemandController(); this.optimizableOrderByCollections = {}; this.orderedLoaders = /* @__PURE__ */ new Map(); this.pendingOrderedPublications = /* @__PURE__ */ new Set(); this.orderedPublicationFailed = false; this.unsubscribeCallbacks = /* @__PURE__ */ new Set(); this.sentToD2RowsBySource = /* @__PURE__ */ new Map(); this.pendingChanges = /* @__PURE__ */ new Map(); this.publishedRows = /* @__PURE__ */ new Map(); this.initialLoadComplete = false; this.subscribedToAllCollections = false; this.builderDependencies = /* @__PURE__ */ new Set(); this.isGraphRunning = false; this.starting = false; this.disposed = false; this.skipInitial = config.skipInitial; this.onBatchProcessed = config.onBatchProcessed; this.onSourceError = config.onSourceError; this.query = utils.buildQueryFromConfig({ query: config.query }); this.queryCompareOptions = utils.extractCollectionFromSource( this.query ).compareOptions; this.collections = utils.extractCollectionsFromQuery(this.query); this.collectionSources = ir.collectCollectionSources(this.query); this.compilePipeline(); } /** Compile the D2 graph and query pipeline */ compilePipeline() { this.graph = new dbIvm.D2(); this.inputs = Object.fromEntries( this.collectionSources.map((source) => [ source.sourceId, this.graph.newInput() ]) ); const compilation = index.compileQuery( this.query, this.inputs, this.collections, // These mutable objects are captured by reference. The join compiler // reads them later when the graph runs, so they must be populated // (in start()) before the first graph run. this.subscriptions, this.lazySourcesCallbacks, this.lazySources, this.optimizableOrderByCollections, () => { } // setWindowFn (no-op — effects don't paginate) ); this.sourceWhereClauses = compilation.sourceWhereClauses; compilation.pipeline.pipe( dbIvm.output((data) => { const messages = data.getInner(); messages.reduce(accumulateEffectChanges, this.pendingChanges); }) ); this.graph.finalize(); } isDisposed() { return this.disposed; } /** Subscribe to source collections and start processing */ start() { this.starting = true; if (this.collectionSources.length === 0) { this.starting = false; return; } if (!this.skipInitial) { this.initialLoadComplete = true; } const pendingBuffers = /* @__PURE__ */ new Map(); for (const source of this.collectionSources) { if (this.isDisposed()) { this.starting = false; return; } const { sourceId, alias, collection } = source; const collectionId = collection.id; this.sentToD2RowsBySource.set(sourceId, /* @__PURE__ */ new Map()); const dependencyBuilder = collectionRegistry.getCollectionBuilder(collection); if (dependencyBuilder) { this.builderDependencies.add(dependencyBuilder); } const whereClause = this.sourceWhereClauses?.get(sourceId); const whereExpression = whereClause ? expressions.normalizeExpressionPaths(whereClause, alias) : void 0; const buffer = []; pendingBuffers.set(sourceId, buffer); const isLazy = this.lazySources.has(sourceId); const orderByInfo = this.optimizableOrderByCollections[sourceId]; const changeCallback = (changes) => { const bufferedChanges = pendingBuffers.get(sourceId); if (bufferedChanges) bufferedChanges.push(changes); else this.handleSourceChanges(sourceId, changes); }; const subscription = collection.subscribeChanges(changeCallback, { ...this.buildSubscriptionOptions( alias, isLazy, orderByInfo, whereExpression ), onLoadSubsetError: ({ error: error$1 }) => { this.onSourceError(error.normalizeError(error$1)); } }); this.subscriptions[sourceId] = subscription; const unsubscribe = () => { delete this.subscriptions[sourceId]; subscription.unsubscribe(); }; if (this.isDisposed()) { unsubscribe(); this.starting = false; return; } this.unsubscribeCallbacks.add(unsubscribe); if (Object.values(this.optimizableOrderByCollections).some( (info) => info.joinedFilterSourceId === sourceId )) { this.unsubscribeCallbacks.add( subscription.on(`status:ready`, () => { if (!this.disposed) this.scheduleGraphRun(); }) ); } const lazyCallbacks = this.lazySourcesCallbacks[sourceId]; if (lazyCallbacks) { lazyCallbacks.setDemand = (plan, keys) => this.setDemand(subscription, plan, keys); for (const plan of lazyCallbacks.plans ?? []) { if (plan.initialKeys.size > 0) { lazyCallbacks.setDemand(plan, plan.initialKeys); } } } if (orderByInfo) { const loader = new orderedSourceLoader.OrderedSourceLoader( orderByInfo, subscription, alias, (result, holdPublication) => { const holdsInitialPublication = this.skipInitial && !this.initialLoadComplete; if (holdPublication || holdsInitialPublication) { this.trackOrderedPublication( Promise.resolve(result), () => subscription.pendingTruncateReplacement ); } }, () => false, void 0, () => orderByInfo.joinedFilterSourceId !== void 0 && this.hasPendingJoinedWork(orderByInfo.joinedFilterSourceId) ); this.orderedLoaders.set(sourceId, loader); loader.start(); } const handleSourceCleanup = () => { if (this.disposed) return; this.onSourceError( new Error( `Source collection '${collectionId}' was cleaned up while effect depends on it` ) ); }; const statusUnsubscribe = collection.on(`status:change`, (event) => { if (this.disposed) return; const { status } = event; if (status === `error`) { this.onSourceError( new Error( `Source collection '${collectionId}' entered error state` ) ); return; } if (status === `cleaned-up`) { handleSourceCleanup(); return; } if (this.skipInitial && !this.initialLoadComplete && this.canCompleteInitialLoad()) { this.initialLoadComplete = true; } }); this.unsubscribeCallbacks.add(statusUnsubscribe); const cleanupStartUnsubscribe = collection._onCleanupStart(handleSourceCleanup); if (this.isDisposed()) { cleanupStartUnsubscribe(); this.starting = false; return; } this.unsubscribeCallbacks.add(cleanupStartUnsubscribe); } this.subscribedToAllCollections = true; for (const [sourceId, buffer] of pendingBuffers) { pendingBuffers.delete(sourceId); for (const changes of buffer) { this.handleSourceChanges(sourceId, changes, false); } } this.runGraph(); if (this.skipInitial && !this.initialLoadComplete && this.canCompleteInitialLoad()) { this.initialLoadComplete = true; } this.starting = false; } /** Handle incoming changes from a source collection */ handleSourceChanges(sourceId, changes, scheduleGraph = true) { if (this.optimizableOrderByCollections[sourceId]) { this.orderedLoaders.get(sourceId)?.onSourceChanges(changes, this.sentToD2RowsBySource.get(sourceId)); changes = [...utils.splitUpdates(changes)]; } this.sendChangesToD2(sourceId, changes); if (scheduleGraph) this.scheduleGraphRun(); } setDemand(subscription, plan, keys) { let update; try { update = this.demand.setDemand(subscription, plan, keys); } catch (error2) { if (!Object.is(subscription.lastError, error2)) throw error2; if (this.starting) throw error2; return; } if (update.ready instanceof Promise) { void update.ready.then( () => { if (!this.disposed) this.scheduleGraphRun(); }, () => { } ); } } /** * Schedule a graph run via the transaction-scoped scheduler. * * When called within a transaction, the run is deferred until the * transaction flushes, coalescing multiple changes into a single graph * execution. Without a transaction, the graph runs immediately. * * Dependencies are discovered from source collections that are themselves * live query collections, ensuring parent queries run before effects. */ scheduleGraphRun() { graphScheduler.scheduleQueryGraphRun(this, this.builderDependencies, () => { if (!this.disposed && this.subscribedToAllCollections) this.runGraph(); }); } /** Hold Effect callback publication across one authoritative repair chain. */ trackOrderedPublication(promise, replacementAfterFailure) { if (this.disposed) return; if (this.pendingOrderedPublications.size === 0) { this.orderedPublicationFailed = false; } this.pendingOrderedPublications.add(promise); const finish = (succeeded) => { if (!this.pendingOrderedPublications.delete(promise)) return; if (!succeeded) this.orderedPublicationFailed = true; if (succeeded && !this.orderedPublicationFailed && this.pendingOrderedPublications.size === 0 && !this.disposed) { this.scheduleGraphRun(); } }; void promise.then( () => finish(true), () => { const replacement = replacementAfterFailure(); if (replacement && this.pendingOrderedPublications.delete(promise) && !this.disposed) { this.trackOrderedPublication(replacement, replacementAfterFailure); return; } finish(false); } ); } /** * Send changes to the D2 input for the given lexical source. * Returns the number of multiset entries sent. */ sendChangesToD2(sourceId, changes) { if (this.disposed || !this.inputs || !this.graph) return 0; const input = this.inputs[sourceId]; if (!input) return 0; const sentRows = this.sentToD2RowsBySource.get(sourceId); const reconciled = utils.reconcileChangesForD2(changes, sentRows); return utils.sendChangesToInput(input, reconciled); } /** * Run the D2 graph until quiescence, then emit accumulated events once. * * All output across the entire while-loop is accumulated into a single * batch so that users see one `onBatchProcessed` invocation per scheduler * run, even when ordered loading causes multiple graph steps. */ runGraph() { if (this.isGraphRunning || this.disposed || !this.graph) return; this.isGraphRunning = true; try { if (!this.graph.pendingWork()) this.loadMoreIfNeeded(); while (!this.isDisposed() && this.graph.pendingWork()) { this.graph.run(); if (this.disposed) break; this.loadMoreIfNeeded(); } this.flushPendingChanges(); if (this.skipInitial && !this.initialLoadComplete && this.canCompleteInitialLoad()) { this.initialLoadComplete = true; } } finally { this.isGraphRunning = false; } } /** Classify accumulated changes into DeltaEvents and invoke the callback */ flushPendingChanges() { if (this.pendingChanges.size === 0) return; if (this.orderedPublicationFailed || this.pendingOrderedPublications.size > 0) { return; } if (this.hasPendingJoinedFilterWork()) { return; } const shouldPublish = !this.skipInitial || this.initialLoadComplete; const tracksPublishedRows = this.orderedLoaders.size > 0; const events = []; for (const [key, changes] of this.pendingChanges) { const event = classifyDelta( key, changes, tracksPublishedRows ? this.publishedRows : void 0 ); if (event) { if (tracksPublishedRows) { if (event.type === `exit`) this.publishedRows.delete(key); else this.publishedRows.set(key, event.value); } if (shouldPublish) events.push(event); } } this.pendingChanges = /* @__PURE__ */ new Map(); if (events.length > 0) { this.onBatchProcessed(events); } } /** Check if all source collections are in the ready state */ checkAllCollectionsReady() { return Object.values(this.collections).every( (collection) => collection.isReady() ); } /** Initial callbacks start only after source readiness and ordered loading. */ canCompleteInitialLoad() { return !this.orderedPublicationFailed && this.pendingOrderedPublications.size === 0 && !this.hasPendingJoinedFilterWork() && this.checkAllCollectionsReady(); } hasPendingJoinedFilterWork() { return Object.values(this.optimizableOrderByCollections).some( (info) => info.joinedFilterSourceId !== void 0 && this.hasPendingJoinedWork(info.joinedFilterSourceId) ); } /** * Build subscription options for an alias based on whether it uses ordered * loading, is lazy, or should pass orderBy/limit hints. */ buildSubscriptionOptions(alias, isLazy, orderByInfo, whereExpression) { if (this.query.limit === 0) { return { includeInitialState: false, whereExpression }; } if (orderByInfo) { return { includeInitialState: false, whereExpression }; } const includeInitialState = !isLazy; const hints = utils.computeSubscriptionOrderByHints( this.query, alias, this.queryCompareOptions ); return { includeInitialState, whereExpression, ...hints.orderBy ? { orderBy: hints.orderBy } : {}, ...hints.limit !== void 0 ? { limit: hints.limit } : {} }; } /** * After each graph run step, check if any ordered query's topK operator * needs more data. If so, load more rows via requestLimitedSnapshot. */ loadMoreIfNeeded() { for (const loader of this.orderedLoaders.values()) { try { loader.loadMore(); } catch (error2) { if (!this.disposed && !Object.values(this.subscriptions).some( (subscription) => Object.is(subscription.lastError, error2) )) throw error2; } } } hasPendingJoinedWork(joinedSourceId) { return subsetDemandController.hasPendingJoinedWork( joinedSourceId, this.lazySourcesCallbacks, this.subscriptions, (planId) => this.demand.hasPendingDemand(planId) ); } /** Tear down subscriptions and clear state */ dispose() { if (this.disposed) return; this.disposed = true; this.subscribedToAllCollections = false; try { callbacks.runAllCallbacks(this.unsubscribeCallbacks); } finally { this.unsubscribeCallbacks.clear(); this.clearPipelineState(); } } clearPipelineState() { this.sentToD2RowsBySource.clear(); this.pendingChanges.clear(); this.publishedRows.clear(); this.pendingOrderedPublications.clear(); this.orderedPublicationFailed = false; this.lazySources.clear(); this.demand.clear(); this.builderDependencies.clear(); for (const loader of this.orderedLoaders.values()) loader.dispose(); this.orderedLoaders.clear(); for (const key of Object.keys(this.lazySourcesCallbacks)) { delete this.lazySourcesCallbacks[key]; } for (const key of Object.keys(this.optimizableOrderByCollections)) { delete this.optimizableOrderByCollections[key]; } this.graph = void 0; this.inputs = void 0; this.sourceWhereClauses = void 0; } } function getHandlerForEvent(event, config) { switch (event.type) { case `enter`: return config.onEnter; case `exit`: return config.onExit; case `update`: return config.onUpdate; } } function accumulateEffectChanges(acc, [[key, tupleData], multiplicity]) { const [value] = tupleData; const changes = acc.get(key) || { deletes: 0, inserts: 0 }; if (multiplicity < 0) { changes.deletes += Math.abs(multiplicity); changes.deleteValue ??= value; } else if (multiplicity > 0) { changes.inserts += multiplicity; changes.insertValue = value; } acc.set(key, changes); return acc; } function classifyDelta(key, changes, publishedRows) { const { inserts, deletes, insertValue, deleteValue } = changes; const wasPresent = publishedRows ? publishedRows.has(key) : deletes > 0; const previousValue = publishedRows?.get(key) ?? deleteValue; const isPresent = publishedRows ? Number(wasPresent) + inserts - deletes > 0 : inserts > 0; if (!wasPresent && isPresent) { return { type: `enter`, key, value: insertValue }; } if (wasPresent && !isPresent) { return { type: `exit`, key, value: previousValue ?? deleteValue }; } if (wasPresent && isPresent && insertValue !== void 0 && (!publishedRows || !utils$1.deepEquals(previousValue, insertValue))) { return { type: `update`, key, value: insertValue, previousValue }; } return void 0; } function trackPromise(promise, inFlightHandlers) { inFlightHandlers.add(promise); const finish = () => { inFlightHandlers.delete(promise); }; void promise.then(finish, finish); } function reportError(error$1, event, onError) { const normalised = error.normalizeError(error$1); if (onError) { try { onError(normalised, event); } catch (onErrorError) { console.error(`[Effect] Error in onError handler:`, onErrorError); console.error(`[Effect] Original error:`, normalised); } } else { console.error(`[Effect] Unhandled error in handler:`, normalised); } } exports.createEffect = createEffect; //# sourceMappingURL=effect.cjs.map