UNPKG

@tanstack/db

Version:

A reactive client store for building super fast apps on sync

750 lines (749 loc) 25.9 kB
"use strict"; Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" }); const dbIvm = require("@tanstack/db-ivm"); const index = require("../compiler/index.cjs"); const errors = require("../../errors.cjs"); const scheduler = require("../../scheduler.cjs"); const transactions = require("../../transactions.cjs"); const utils$1 = require("../../utils.cjs"); const collectionSubscriber = require("./collection-subscriber.cjs"); const collectionRegistry = require("./collection-registry.cjs"); const internal = require("./internal.cjs"); const materializedPipeline = require("./materialized-pipeline.cjs"); const bucketFacadeAdapter = require("./bucket-facade-adapter.cjs"); const utils = require("./utils.cjs"); const ir = require("../ir.cjs"); let liveQueryCollectionCounter = 0; class CollectionConfigBuilder { constructor(config) { this.config = config; this.compiledAliasToCollectionId = {}; this.resultKeys = /* @__PURE__ */ new WeakMap(); this.orderByIndices = /* @__PURE__ */ new WeakMap(); this.isGraphRunning = false; this.runCount = 0; this.isInErrorState = false; this.sourceDependencies = {}; this.builderDependencies = /* @__PURE__ */ new Set(); this.pendingGraphRuns = /* @__PURE__ */ new Map(); this.subscriptions = {}; this.lazySourcesCallbacks = {}; this.lazySources = /* @__PURE__ */ new Set(); this.activeDemands = /* @__PURE__ */ new Map(); this.demandGenerations = /* @__PURE__ */ new Map(); this.optimizableOrderByCollections = {}; this.id = config.id || `live-query-${++liveQueryCollectionCounter}`; this.query = utils.buildQueryFromConfig({ query: config.query, requireObjectResult: true }); this.initialWindow = this.query.orderBy?.length ? { offset: this.query.offset ?? 0, limit: this.query.limit ?? Infinity } : void 0; this.collections = utils.extractCollectionsFromQuery(this.query); this.collectionSources = ir.collectCollectionSources(this.query); this.collectionByAlias = Object.fromEntries( this.collectionSources.map(({ alias, collection }) => [ alias, collection ]) ); if (this.query.orderBy && this.query.orderBy.length > 0) { this.compare = createOrderByComparator(this.orderByIndices); } this.compareOptions = this.config.defaultStringCollation ?? utils.extractCollectionFromSource(this.query).compareOptions; this.compileBasePipeline(); } /** * Recursively checks if a query or any of its subqueries contains joins */ hasJoins(query) { if (query.join && query.join.length > 0) { return true; } if (query.from.type === `queryRef`) { if (this.hasJoins(query.from.query)) { return true; } } else if (query.from.type === `unionFrom`) { for (const source of query.from.sources) { if (source.type === `queryRef` && this.hasJoins(source.query)) { return true; } } } else if (query.from.type === `unionAll`) { for (const branch of query.from.queries) { if (this.hasJoins(branch)) { return true; } } } return false; } getConfig() { return { id: this.id, getKey: this.config.getKey || ((item) => this.resultKeys.get(item) ?? item.$key), sync: this.getSyncConfig(), compare: this.compare, defaultStringCollation: this.compareOptions, gcTime: this.config.gcTime ?? 5e3, // 5 seconds by default for live queries schema: this.config.schema, onInsert: this.config.onInsert, onUpdate: this.config.onUpdate, onDelete: this.config.onDelete, startSync: this.config.startSync, singleResult: this.query.singleResult, utils: { getRunCount: this.getRunCount.bind(this), setWindow: this.setWindow.bind(this), getWindow: this.getWindow.bind(this), [internal.LIVE_QUERY_INTERNAL]: { getBuilder: () => this, hasCustomGetKey: !!this.config.getKey, hasJoins: this.hasJoins(this.query), hasDistinct: !!this.query.distinct } } }; } setWindow(options) { if (!this.windowFn) { throw new errors.SetWindowRequiresOrderByError(); } const previousWindow = this.currentWindow ?? this.initialWindow; try { this.windowFn(options); this.maybeRunGraphFn?.(); this.currentWindow = options; } catch (error) { if (previousWindow) { try { this.windowFn(previousWindow); this.maybeRunGraphFn?.(); } catch { } } throw error; } return this.liveQueryCollection?._sync.waitForCurrentLoadSubset() ?? true; } getWindow() { const window = this.currentWindow ?? this.initialWindow; if (!this.windowFn || !window) { return void 0; } return { offset: window.offset ?? 0, limit: window.limit ?? 0 }; } /** * Resolves a collection alias to its collection ID. * * Uses a two-tier lookup strategy: * 1. First checks compiled aliases (includes subquery inner aliases) * 2. Falls back to declared aliases from the query's from/join clauses * * @param alias - The alias to resolve (e.g., "employee", "manager") * @returns The collection ID that the alias references * @throws {Error} If the alias is not found in either lookup */ getCollectionIdForAlias(alias) { const compiled = this.compiledAliasToCollectionId[alias]; if (compiled) { return compiled; } const collection = this.collectionByAlias[alias]; if (collection) { return collection.id; } throw new Error(`Unknown source alias "${alias}"`); } isLazySource(sourceId) { return this.lazySources.has(sourceId); } beginDemand(planId) { const generation = (this.demandGenerations.get(planId) ?? 0) + 1; this.demandGenerations.set(planId, generation); this.activeDemands.set(planId, { generation, settled: false }); return generation; } settleDemand(planId, generation) { const demand = this.activeDemands.get(planId); if (!demand || demand.generation !== generation || demand.settled) return; demand.settled = true; this.maybeRunGraphFn?.(); } failDemand(planId, generation, error) { const demand = this.activeDemands.get(planId); if (!demand || demand.generation !== generation) return; const message = error instanceof Error ? error.message : String(error); this.transitionToError(`Subset demand '${planId}' failed: ${message}`); } retireDemand(planId) { this.activeDemands.delete(planId); } // The callback function is called after the graph has run. // This gives the callback a chance to load more data if needed, // that's used to optimize orderBy operators that set a limit, // in order to load some more data if we still don't have enough rows after the pipeline has run. // That can happen because even though we load N rows, the pipeline might filter some of these rows out // causing the orderBy operator to receive less than N rows or even no rows at all. // So this callback would notice that it doesn't have enough rows and load some more. // The callback returns a boolean, when it's true it's done loading data and we can mark the collection as ready. maybeRunGraph(callback) { if (this.isGraphRunning) { return; } if (!this.currentSyncConfig || !this.currentSyncState) { throw new Error( `maybeRunGraph called without active sync session. This should not happen.` ); } this.isGraphRunning = true; try { const { begin, commit } = this.currentSyncConfig; const syncState = this.currentSyncState; if (this.isInErrorState) { return; } if (syncState.subscribedToAllCollections) { let callbackCalled = false; while (syncState.graph.pendingWork()) { syncState.graph.run(); callback?.(); callbackCalled = true; } syncState.flushPendingChanges?.(); if (!callbackCalled) { callback?.(); } if (syncState.messagesCount === 0) { begin(); commit(); } this.updateLiveQueryStatus(this.currentSyncConfig); } } finally { this.isGraphRunning = false; } } /** * Schedules a graph run with the transaction-scoped scheduler. * Ensures each builder runs at most once per transaction, with automatic dependency tracking * to run parent queries before child queries. Outside a transaction, runs immediately. * * Multiple calls during a transaction are coalesced into a single execution. * Dependencies are auto-discovered from subscribed live queries, or can be overridden. * Load callbacks are combined when entries merge. * * Uses the current sync session's config and syncState from instance properties. * * @param callback - Optional callback to load more data if needed (returns true when done) * @param options - Optional scheduling configuration * @param options.contextId - Transaction ID to group work; defaults to active transaction * @param options.jobId - Unique identifier for this job; defaults to this builder instance * @param options.sourceId - Source that triggered this schedule; adds its dependencies * @param options.dependencies - Explicit dependency list; overrides auto-discovered dependencies */ scheduleGraphRun(callback, options) { const contextId = options?.contextId ?? transactions.getActiveTransaction()?.id ?? scheduler.getActivePublicationContext(); const jobId = options?.jobId ?? this; const dependentBuilders = (() => { if (options?.dependencies) { return options.dependencies; } const deps = new Set(this.builderDependencies); if (options?.sourceId) { const sourceDeps = this.sourceDependencies[options.sourceId]; if (sourceDeps) { for (const dep of sourceDeps) { deps.add(dep); } } } deps.delete(this); return Array.from(deps); })(); if (contextId) { for (const dep of dependentBuilders) { if (typeof dep.scheduleGraphRun === `function`) { dep.scheduleGraphRun(void 0, { contextId }); } } } if (!this.currentSyncConfig || !this.currentSyncState) { throw new Error( `scheduleGraphRun called without active sync session. This should not happen.` ); } let pending = contextId ? this.pendingGraphRuns.get(contextId) : void 0; if (!pending) { pending = { loadCallbacks: /* @__PURE__ */ new Set() }; if (contextId) { this.pendingGraphRuns.set(contextId, pending); } } if (callback) { pending.loadCallbacks.add(callback); } const pendingToPass = contextId ? void 0 : pending; scheduler.transactionScopedScheduler.schedule({ contextId, jobId, dependencies: dependentBuilders, run: () => this.executeGraphRun(contextId, pendingToPass) }); } /** * Clears pending graph run state for a specific context. * Called when the scheduler clears a context (e.g., transaction rollback/abort). */ clearPendingGraphRun(contextId) { this.pendingGraphRuns.delete(contextId); } /** * Returns true if this builder has a pending graph run for the given context. */ hasPendingGraphRun(contextId) { return this.pendingGraphRuns.has(contextId); } /** * Executes a pending graph run. Called by the scheduler when dependencies are satisfied. * Clears the pending state BEFORE execution so that any re-schedules during the run * create fresh state and don't interfere with the current execution. * Uses instance sync state - if sync has ended, gracefully returns without executing. * * @param contextId - Optional context ID to look up pending state * @param pendingParam - For immediate execution (no context), pending state is passed directly */ executeGraphRun(contextId, pendingParam) { const pending = pendingParam ?? (contextId ? this.pendingGraphRuns.get(contextId) : void 0); if (contextId) { this.pendingGraphRuns.delete(contextId); } if (!pending) { return; } if (!this.currentSyncConfig || !this.currentSyncState) { return; } this.incrementRunCount(); const combinedLoader = () => { let allDone = true; let firstError; pending.loadCallbacks.forEach((loader) => { try { allDone = loader() && allDone; } catch (error) { allDone = false; firstError ??= error; } }); if (firstError) { throw firstError; } return allDone; }; this.maybeRunGraph(combinedLoader); } getSyncConfig() { return { rowUpdateMode: `full`, sync: this.syncFn.bind(this) }; } incrementRunCount() { this.runCount++; } getRunCount() { return this.runCount; } syncFn(config) { this.liveQueryCollection = config.collection; this.isInErrorState = false; this.currentSyncConfig = config; const syncState = { messagesCount: 0, subscribedToAllCollections: false, unsubscribeCallbacks: /* @__PURE__ */ new Set() }; const fullSyncState = this.extendPipelineWithChangeProcessing( config, syncState ); this.currentSyncState = fullSyncState; this.unsubscribeFromSchedulerClears = scheduler.transactionScopedScheduler.onClear( (contextId) => { this.clearPendingGraphRun(contextId); } ); const loadingSubsetUnsubscribe = config.collection.on( `loadingSubset:change`, (event) => { if (!event.isLoadingSubset) { this.updateLiveQueryStatus(config); } } ); syncState.unsubscribeCallbacks.add(loadingSubsetUnsubscribe); const loadSubsetDataCallbacks = this.subscribeToAllCollections( config, fullSyncState ); this.maybeRunGraphFn = () => this.scheduleGraphRun(loadSubsetDataCallbacks); this.scheduleGraphRun(loadSubsetDataCallbacks); return () => { syncState.unsubscribeCallbacks.forEach((unsubscribe) => unsubscribe()); this.currentSyncConfig = void 0; this.currentSyncState = void 0; this.maybeRunGraphFn = void 0; this.currentWindow = void 0; this.pendingGraphRuns.clear(); this.graphCache = void 0; this.inputsCache = void 0; this.pipelineCache = void 0; this.sourceWhereClausesCache = void 0; this.bucketFacadesCache = void 0; this.lazySources.clear(); this.demandGenerations.clear(); this.activeDemands.clear(); this.optimizableOrderByCollections = {}; this.lazySourcesCallbacks = {}; Object.keys(this.subscriptions).forEach( (key) => delete this.subscriptions[key] ); this.compiledAliasToCollectionId = {}; this.unsubscribeFromSchedulerClears?.(); this.unsubscribeFromSchedulerClears = void 0; }; } /** * Compiles the query pipeline with all declared aliases. */ compileBasePipeline() { this.graphCache = new dbIvm.D2(); this.inputsCache = Object.fromEntries( this.collectionSources.map((source) => [ source.sourceId, this.graphCache.newInput() ]) ); const compilation = index.compileQuery( this.query, this.inputsCache, this.collections, this.subscriptions, this.lazySourcesCallbacks, this.lazySources, this.optimizableOrderByCollections, (windowFn) => { this.windowFn = windowFn; if (this.currentWindow) { windowFn(this.currentWindow); } } ); const materialized = materializedPipeline.materializeCompilation( compilation, this.config.getKey, this.hasJoins(this.query) ); this.pipelineCache = materialized.pipeline; this.sourceWhereClausesCache = compilation.sourceWhereClauses; this.compiledAliasToCollectionId = compilation.aliasToCollectionId; this.bucketFacadesCache = materialized.facades; const missingSources = this.collectionSources.map((source) => source.sourceId).filter((sourceId) => !Object.hasOwn(this.inputsCache, sourceId)); if (missingSources.length > 0) { throw new errors.MissingAliasInputsError(missingSources); } } maybeCompileBasePipeline() { if (!this.graphCache || !this.inputsCache || !this.pipelineCache) { this.compileBasePipeline(); } return { graph: this.graphCache, inputs: this.inputsCache, pipeline: this.pipelineCache }; } extendPipelineWithChangeProcessing(config, syncState) { const { begin, commit } = config; const { graph, inputs, pipeline } = this.maybeCompileBasePipeline(); let pendingChanges = /* @__PURE__ */ new Map(); pipeline.pipe( dbIvm.output((data) => { const messages = data.getInner(); syncState.messagesCount += messages.length; messages.reduce(accumulateChanges, pendingChanges); }) ); const bucketFacades = new bucketFacadeAdapter.BucketFacadeAdapter( this.id, this.bucketFacadesCache ?? [], (count) => { syncState.messagesCount += count; } ); syncState.unsubscribeCallbacks.add(() => bucketFacades.cleanup()); syncState.flushPendingChanges = () => { const hasParentChanges = pendingChanges.size > 0; const hasChildChanges = bucketFacades.hasPendingChanges(); if (!hasParentChanges && !hasChildChanges) { return; } let facadePublication; let rootPublication; try { facadePublication = bucketFacades.flush(); rootPublication = hasParentChanges ? config.collection._deferPublication() : void 0; const changesToApply = new Map( [...pendingChanges].map(([key, changes]) => { const resolved = { ...changes, value: bucketFacades.resolve(changes.value) }; if (changes.previousValue !== void 0) { resolved.previousValue = bucketFacades.resolve( changes.previousValue ); } return [key, resolved]; }) ); if (hasParentChanges) { begin(); changesToApply.forEach(this.applyChanges.bind(this, config)); if (hasOrderOnlyMove(changesToApply)) { markLayoutChange(config.collection); } commit(); } } catch (error) { pendingChanges = /* @__PURE__ */ new Map(); rootPublication?.discard(); facadePublication?.rollback(); throw error; } pendingChanges = /* @__PURE__ */ new Map(); let publicationError; for (const publish of [ rootPublication?.publish, facadePublication.publish ]) { if (!publish) continue; try { publish(); } catch (error) { publicationError ??= error; } } if (publicationError !== void 0) throw publicationError; }; graph.finalize(); syncState.graph = graph; syncState.inputs = inputs; syncState.pipeline = pipeline; return syncState; } applyChanges(config, changes, key) { const { write, collection } = config; const { deletes, inserts, value, orderByIndex } = changes; this.resultKeys.set(value, key); if (orderByIndex !== void 0) { this.orderByIndices.set(value, orderByIndex); } if (inserts && deletes === 0) { write({ value, type: `insert` }); } else if ( // Insert & update(s) (updates are a delete & insert) inserts > deletes || // Just update(s) but the item is already in the collection (so // was inserted previously). inserts === deletes && collection.has(collection.getKeyFromItem(value)) ) { write({ value, type: `update` }); } else if (deletes > 0) { write({ value, type: `delete` }); } else { throw new Error( `Could not apply changes: ${JSON.stringify(changes)}. This should never happen.` ); } } /** * Handle status changes from source collections */ handleSourceStatusChange(config, collectionId, event) { const { status } = event; if (status === `error`) { this.transitionToError( `Source collection '${collectionId}' entered error state` ); return; } if (status === `cleaned-up`) { this.transitionToError( `Source collection '${collectionId}' was manually cleaned up while live query '${this.id}' depends on it. Live queries prevent automatic GC, so this was likely a manual cleanup() call.` ); return; } this.updateLiveQueryStatus(config); } /** * Update the live query status based on source collection statuses */ updateLiveQueryStatus(config) { const { markReady } = config; if (this.isInErrorState) { return; } const subscribedToAll = this.currentSyncState?.subscribedToAllCollections; const allReady = this.allRequiredSourcesReady(); const allDemandsSettled = [...this.activeDemands.values()].every( (demand) => demand.settled ); const isLoading = this.liveQueryCollection?.isLoadingSubset; if (subscribedToAll && allReady && allDemandsSettled && !isLoading) { markReady(); } } /** * Transition the live query to error state */ transitionToError(message) { this.isInErrorState = true; console.error(`[Live Query Error] ${message}`); this.liveQueryCollection?._lifecycle.setStatus(`error`); } allRequiredSourcesReady() { return this.collectionSources.every( (source) => ( // Only on-demand sources settle through route demand. Eager // loadSubset calls return immediately, so they must reach ready. this.lazySources.has(source.sourceId) && source.collection.config.syncMode === `on-demand` || source.collection.isReady() ) ); } /** * Creates one subscription per lexical collection source. * Each source gets independent filters, even when aliases or collections repeat. * Example: `{ employee: col, manager: col }` creates two separate subscriptions. */ subscribeToAllCollections(config, syncState) { if (this.collectionSources.length === 0) { throw new Error( `Query '${this.id}' has no collection sources. This should not happen; please report.` ); } const loaders = this.collectionSources.map((source) => { const { sourceId, alias, collection } = source; const collectionId = collection.id; const dependencyBuilder = collectionRegistry.getCollectionBuilder(collection); if (dependencyBuilder && dependencyBuilder !== this) { this.sourceDependencies[sourceId] = [dependencyBuilder]; this.builderDependencies.add(dependencyBuilder); } else { this.sourceDependencies[sourceId] = []; } const collectionSubscriber$1 = new collectionSubscriber.CollectionSubscriber( sourceId, alias, collection, this ); const statusUnsubscribe = collection.on(`status:change`, (event) => { this.handleSourceStatusChange(config, collectionId, event); }); syncState.unsubscribeCallbacks.add(statusUnsubscribe); const subscription = collectionSubscriber$1.subscribe(); this.subscriptions[sourceId] = subscription; const lazyCallbacks = this.lazySourcesCallbacks[sourceId]; if (lazyCallbacks) { lazyCallbacks.setDemand = (plan, keys) => collectionSubscriber$1.setDemand(subscription, plan, keys); for (const plan of lazyCallbacks.plans ?? []) { if (plan.initialKeys.size > 0) { lazyCallbacks.setDemand(plan, plan.initialKeys); } } } const loadMore = collectionSubscriber$1.loadMoreIfNeeded.bind( collectionSubscriber$1, subscription ); return loadMore; }); const loadSubsetDataCallbacks = () => { loaders.map((loader) => loader()); return true; }; syncState.subscribedToAllCollections = true; return loadSubsetDataCallbacks; } } function createOrderByComparator(orderByIndices) { return (val1, val2) => { const index1 = orderByIndices.get(val1); const index2 = orderByIndices.get(val2); if (index1 && index2) { if (index1 < index2) { return -1; } else if (index1 > index2) { return 1; } else { return 0; } } return 0; }; } function accumulateChanges(acc, [[key, tupleData], multiplicity]) { const [value, orderByIndex] = tupleData; const changes = acc.get(key) || { deletes: 0, inserts: 0, value, orderByIndex }; if (multiplicity < 0) { changes.deletes += Math.abs(multiplicity); changes.previousValue = value; changes.previousOrderByIndex = orderByIndex; } else if (multiplicity > 0) { changes.inserts += multiplicity; changes.value = value; if (orderByIndex !== void 0) { changes.orderByIndex = orderByIndex; } } acc.set(key, changes); return acc; } function hasOrderOnlyMove(changesToApply) { for (const changes of changesToApply.values()) { const isUpdate = changes.inserts > 0 && changes.deletes > 0; if (isUpdate && changes.previousValue !== void 0 && utils$1.deepEquals(changes.previousValue, changes.value) && changes.orderByIndex !== changes.previousOrderByIndex) { return true; } } return false; } function markLayoutChange(collection) { collection._markLayoutChange(); } exports.CollectionConfigBuilder = CollectionConfigBuilder; //# sourceMappingURL=collection-config-builder.cjs.map