{"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    // Preserve the original request for tracking and in-flight dedupe, but allow\n    // the backend request to be narrowed to only the missing subset.\n    const trackingOptions = cloneOptions(options)\n    const loadOptions = 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      loadOptions.where =\n        minusWherePredicates(loadOptions.where, this.unlimitedWhere) ??\n        loadOptions.where\n    }\n\n    // Call underlying loadSubset to load the missing data\n    const resultPromise = this._loadSubset(loadOptions)\n\n    // Handle both sync (true) and async (Promise<void>) return values\n    if (resultPromise === true) {\n      this.updateTracking(trackingOptions)\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: trackingOptions,\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              this.updateTracking(trackingOptions)\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 {\n    ...options,\n    orderBy: options.orderBy?.map((clause) => ({\n      ...clause,\n      compareOptions: { ...clause.compareOptions },\n    })),\n    cursor: options.cursor ? { ...options.cursor } : undefined,\n  }\n}\n"],"names":["isWhereSubset","isPredicateSubset","minusWherePredicates","unionWherePredicates"],"mappings":"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"}