UNPKG

@tanstack/db

Version:

A reactive client store for building super fast apps on sync

1 lines 12.8 kB
{"version":3,"file":"subset-dedupe.cjs","sources":["../../../src/query/subset-dedupe.ts"],"sourcesContent":["import {\n isPredicateSubset,\n isWhereSubset,\n minusWherePredicates,\n unionWherePredicates,\n} from './predicate-utils.js'\nimport type { BasicExpression } from './ir.js'\nimport type { LoadSubsetOptions } from '../types.js'\n\n/**\n * Deduplicated wrapper for a loadSubset function.\n * Tracks what data has been loaded and avoids redundant calls by applying\n * subset logic to predicates.\n *\n * @param opts - The options for the DeduplicatedLoadSubset\n * @param opts.loadSubset - The underlying loadSubset function to wrap\n * @param opts.onDeduplicate - An optional callback function that is invoked when a loadSubset call is deduplicated.\n * If the call is deduplicated because the requested data is being loaded by an inflight request,\n * then this callback is invoked when the inflight request completes successfully and the data is fully loaded.\n * This callback is useful if you need to track rows per query, in which case you can't ignore deduplicated calls\n * because you need to know which rows were loaded for each query.\n * @example\n * const dedupe = new DeduplicatedLoadSubset({ loadSubset: myLoadSubset, onDeduplicate: (opts) => console.log(`Call was deduplicated:`, opts) })\n *\n * // First call - fetches data\n * await dedupe.loadSubset({ where: gt(ref('age'), val(10)) })\n *\n * // Second call - subset of first, returns true immediately\n * await dedupe.loadSubset({ where: gt(ref('age'), val(20)) })\n *\n * // Clear state to start fresh\n * dedupe.reset()\n */\nexport class DeduplicatedLoadSubset {\n // The underlying loadSubset function to wrap\n private readonly _loadSubset: (\n options: LoadSubsetOptions,\n ) => true | Promise<void>\n\n // An optional callback function that is invoked when a loadSubset call is deduplicated.\n private readonly onDeduplicate:\n | ((options: LoadSubsetOptions) => void)\n | undefined\n\n // Combined where predicate for all unlimited calls (no limit)\n private unlimitedWhere: BasicExpression<boolean> | undefined = undefined\n\n // Flag to track if we've loaded all data (unlimited call with no where clause)\n private hasLoadedAllData = false\n\n // List of all limited calls (with limit, possibly with orderBy)\n // We clone options before storing to prevent mutation of stored predicates\n private limitedCalls: Array<LoadSubsetOptions> = []\n\n // Track in-flight calls to prevent concurrent duplicate requests\n // We store both the options and the promise so we can apply subset logic\n private inflightCalls: Array<{\n options: LoadSubsetOptions\n promise: Promise<void>\n }> = []\n\n // Generation counter to invalidate in-flight requests after reset()\n // When reset() is called, this increments, and any in-flight completion handlers\n // check if their captured generation matches before updating tracking state\n private generation = 0\n\n constructor(opts: {\n loadSubset: (options: LoadSubsetOptions) => true | Promise<void>\n onDeduplicate?: (options: LoadSubsetOptions) => void\n }) {\n this._loadSubset = opts.loadSubset\n this.onDeduplicate = opts.onDeduplicate\n }\n\n /**\n * Load a subset of data, with automatic deduplication based on previously\n * loaded predicates and in-flight requests.\n *\n * This method is auto-bound, so it can be safely passed as a callback without\n * losing its `this` context (e.g., `loadSubset: dedupe.loadSubset` in a sync config).\n *\n * @param options - The predicate options (where, orderBy, limit)\n * @returns true if data is already loaded, or a Promise that resolves when data is loaded\n */\n loadSubset = (options: LoadSubsetOptions): true | Promise<void> => {\n // If we've loaded all data, everything is covered\n if (this.hasLoadedAllData) {\n this.onDeduplicate?.(options)\n return true\n }\n\n // Check against unlimited combined predicate\n // If we've loaded all data matching a where clause, we don't need to refetch subsets\n if (this.unlimitedWhere !== undefined && options.where !== undefined) {\n if (isWhereSubset(options.where, this.unlimitedWhere)) {\n this.onDeduplicate?.(options)\n return true // Data already loaded via unlimited call\n }\n }\n\n // Check against limited calls\n if (options.limit !== undefined) {\n const alreadyLoaded = this.limitedCalls.some((loaded) =>\n isPredicateSubset(options, loaded),\n )\n\n if (alreadyLoaded) {\n this.onDeduplicate?.(options)\n return true // Already loaded\n }\n }\n\n // Check against in-flight calls using the same subset logic as resolved calls\n // This prevents duplicate requests when concurrent calls have subset relationships\n const matchingInflight = this.inflightCalls.find((inflight) =>\n isPredicateSubset(options, inflight.options),\n )\n\n if (matchingInflight !== undefined) {\n // An in-flight call will load data that covers this request\n // Return the same promise so this caller waits for the data to load\n // The in-flight promise already handles tracking updates when it completes\n const prom = matchingInflight.promise\n // Call `onDeduplicate` when the inflight request has loaded the data\n prom.then(() => this.onDeduplicate?.(options)).catch() // ignore errors\n return prom\n }\n\n // Not fully covered by existing data\n // Compute the subset of data that is not covered by the existing data\n // such that we only have to load that subset of missing data\n const clonedOptions = cloneOptions(options)\n if (this.unlimitedWhere !== undefined && options.limit === undefined) {\n // Compute difference to get only the missing data\n // We can only do this for unlimited queries\n // and we can only remove data that was loaded from unlimited queries\n // because with limited queries we have no way to express that we already loaded part of the matching data\n clonedOptions.where =\n minusWherePredicates(clonedOptions.where, this.unlimitedWhere) ??\n clonedOptions.where\n }\n\n // Call underlying loadSubset to load the missing data\n const resultPromise = this._loadSubset(clonedOptions)\n\n // Handle both sync (true) and async (Promise<void>) return values\n if (resultPromise === true) {\n // Sync return - update tracking synchronously\n // Clone options before storing to protect against caller mutation\n this.updateTracking(clonedOptions)\n return true\n } else {\n // Async return - track the promise and update tracking after it resolves\n\n // Capture the current generation - this lets us detect if reset() was called\n // while this request was in-flight, so we can skip updating tracking state\n const capturedGeneration = this.generation\n\n // We need to create a reference to the in-flight entry so we can remove it later\n const inflightEntry = {\n options: clonedOptions, // Store cloned options for subset matching\n promise: resultPromise\n .then((result) => {\n // Only update tracking if this request is still from the current generation\n // If reset() was called, the generation will have incremented and we should\n // not repopulate the state that was just cleared\n if (capturedGeneration === this.generation) {\n // Use the cloned options that we captured before any caller mutations\n // This ensures we track exactly what was loaded, not what the caller changed\n this.updateTracking(clonedOptions)\n }\n return result\n })\n .finally(() => {\n // Always remove from in-flight array on completion OR rejection\n // This ensures failed requests can be retried instead of being cached forever\n const index = this.inflightCalls.indexOf(inflightEntry)\n if (index !== -1) {\n this.inflightCalls.splice(index, 1)\n }\n }),\n }\n\n // Store the in-flight entry so concurrent subset calls can wait for it\n this.inflightCalls.push(inflightEntry)\n return inflightEntry.promise\n }\n }\n\n /**\n * Reset all tracking state.\n * Clears the history of loaded predicates and in-flight calls.\n * Use this when you want to start fresh, for example after clearing the underlying data store.\n *\n * Note: Any in-flight requests will still complete, but they will not update the tracking\n * state after the reset. This prevents old requests from repopulating cleared state.\n */\n reset(): void {\n this.unlimitedWhere = undefined\n this.hasLoadedAllData = false\n this.limitedCalls = []\n this.inflightCalls = []\n // Increment generation to invalidate any in-flight completion handlers\n // This ensures requests that were started before reset() don't repopulate the state\n this.generation++\n }\n\n private updateTracking(options: LoadSubsetOptions): void {\n // Update tracking based on whether this was a limited or unlimited call\n if (options.limit === undefined) {\n // Unlimited call - update combined where predicate\n // We ignore orderBy for unlimited calls as mentioned in requirements\n if (options.where === undefined) {\n // No where clause = all data loaded\n this.hasLoadedAllData = true\n this.unlimitedWhere = undefined\n this.limitedCalls = []\n this.inflightCalls = []\n } else if (this.unlimitedWhere === undefined) {\n this.unlimitedWhere = options.where\n } else {\n this.unlimitedWhere = unionWherePredicates([\n this.unlimitedWhere,\n options.where,\n ])\n }\n } else {\n // Limited call - add to list for future subset checks\n // Options are already cloned by caller to prevent mutation issues\n this.limitedCalls.push(options)\n }\n }\n}\n\n/**\n * Clones a LoadSubsetOptions object to prevent mutation of stored predicates.\n * This is crucial because callers often reuse the same options object and mutate\n * properties like limit or where between calls. Without cloning, our stored history\n * would reflect the mutated values rather than what was actually loaded.\n */\nexport function cloneOptions(options: LoadSubsetOptions): LoadSubsetOptions {\n return { ...options }\n}\n"],"names":["isWhereSubset","isPredicateSubset","minusWherePredicates","unionWherePredicates"],"mappings":"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"}