@@ -7,50 +7,83 @@ import type { BasicExpression } from "./ir.js"
77import type { LoadSubsetOptions } from "../types.js"
88
99/**
10- * Creates a deduplicated wrapper around a loadSubset function.
11- * Tracks what data has been loaded and avoids redundant calls.
12- *
13- * @param loadSubset - The underlying loadSubset function to wrap
14- * @returns A wrapped function that deduplicates calls based on loaded predicates
10+ * Deduplicated wrapper for a loadSubset function.
11+ * Tracks what data has been loaded and avoids redundant calls by applying
12+ * subset logic to predicates.
1513 *
1614 * @example
17- * const deduplicatedLoadSubset = createDeduplicatedLoadSubset (myLoadSubset)
15+ * const dedupe = new DeduplicatedLoadSubset (myLoadSubset)
1816 *
1917 * // First call - fetches data
20- * await deduplicatedLoadSubset ({ where: gt(ref('age'), val(10)) })
18+ * await dedupe.loadSubset ({ where: gt(ref('age'), val(10)) })
2119 *
2220 * // Second call - subset of first, returns true immediately
23- * await deduplicatedLoadSubset({ where: gt(ref('age'), val(20)) })
21+ * await dedupe.loadSubset({ where: gt(ref('age'), val(20)) })
22+ *
23+ * // Clear state to start fresh
24+ * dedupe.reset()
2425 */
25- export function createDeduplicatedLoadSubset (
26- loadSubset : ( options : LoadSubsetOptions ) => true | Promise < void >
27- ) : ( options : LoadSubsetOptions ) => true | Promise < void > {
26+ export class DeduplicatedLoadSubset {
27+ // The underlying loadSubset function to wrap
28+ private readonly _loadSubset : (
29+ options : LoadSubsetOptions
30+ ) => true | Promise < void >
31+
2832 // Combined where predicate for all unlimited calls (no limit)
29- let unlimitedWhere : BasicExpression < boolean > | undefined = undefined
33+ private unlimitedWhere : BasicExpression < boolean > | undefined = undefined
3034
3135 // Flag to track if we've loaded all data (unlimited call with no where clause)
32- let hasLoadedAllData = false
36+ private hasLoadedAllData = false
3337
3438 // List of all limited calls (with limit, possibly with orderBy)
35- const limitedCalls : Array < LoadSubsetOptions > = [ ]
39+ // We clone options before storing to prevent mutation of stored predicates
40+ private limitedCalls : Array < LoadSubsetOptions > = [ ]
41+
42+ // Track in-flight calls to prevent concurrent duplicate requests
43+ // We store both the options and the promise so we can apply subset logic
44+ private inflightCalls : Array < {
45+ options : LoadSubsetOptions
46+ promise : Promise < void >
47+ } > = [ ]
48+
49+ // Generation counter to invalidate in-flight requests after reset()
50+ // When reset() is called, this increments, and any in-flight completion handlers
51+ // check if their captured generation matches before updating tracking state
52+ private generation = 0
53+
54+ constructor (
55+ loadSubset : ( options : LoadSubsetOptions ) => true | Promise < void >
56+ ) {
57+ this . _loadSubset = loadSubset
58+ }
3659
37- return ( options : LoadSubsetOptions ) => {
60+ /**
61+ * Load a subset of data, with automatic deduplication based on previously
62+ * loaded predicates and in-flight requests.
63+ *
64+ * This method is auto-bound, so it can be safely passed as a callback without
65+ * losing its `this` context (e.g., `loadSubset: dedupe.loadSubset` in a sync config).
66+ *
67+ * @param options - The predicate options (where, orderBy, limit)
68+ * @returns true if data is already loaded, or a Promise that resolves when data is loaded
69+ */
70+ loadSubset = ( options : LoadSubsetOptions ) : true | Promise < void > => {
3871 // If we've loaded all data, everything is covered
39- if ( hasLoadedAllData ) {
72+ if ( this . hasLoadedAllData ) {
4073 return true
4174 }
4275
4376 // Check against unlimited combined predicate
4477 // If we've loaded all data matching a where clause, we don't need to refetch subsets
45- if ( unlimitedWhere !== undefined && options . where !== undefined ) {
46- if ( isWhereSubset ( options . where , unlimitedWhere ) ) {
78+ if ( this . unlimitedWhere !== undefined && options . where !== undefined ) {
79+ if ( isWhereSubset ( options . where , this . unlimitedWhere ) ) {
4780 return true // Data already loaded via unlimited call
4881 }
4982 }
5083
5184 // Check against limited calls
5285 if ( options . limit !== undefined ) {
53- const alreadyLoaded = limitedCalls . some ( ( loaded ) =>
86+ const alreadyLoaded = this . limitedCalls . some ( ( loaded ) =>
5487 isPredicateSubset ( options , loaded )
5588 )
5689
@@ -59,40 +92,152 @@ export function createDeduplicatedLoadSubset(
5992 }
6093 }
6194
95+ // Check against in-flight calls using the same subset logic as resolved calls
96+ // This prevents duplicate requests when concurrent calls have subset relationships
97+ const matchingInflight = this . inflightCalls . find ( ( inflight ) => {
98+ // For unlimited calls, check if the incoming where is a subset of the in-flight where
99+ if ( inflight . options . limit === undefined && options . limit === undefined ) {
100+ // Both unlimited - check where subset
101+ if ( inflight . options . where === undefined ) {
102+ // In-flight is loading all data, so incoming is covered
103+ return true
104+ }
105+ if ( options . where !== undefined ) {
106+ return isWhereSubset ( options . where , inflight . options . where )
107+ }
108+ return false
109+ }
110+
111+ // For limited calls, use the full predicate subset check (where + orderBy + limit)
112+ if ( inflight . options . limit !== undefined && options . limit !== undefined ) {
113+ return isPredicateSubset ( options , inflight . options )
114+ }
115+
116+ // Mixed unlimited/limited - limited calls can be covered by unlimited calls
117+ if ( inflight . options . limit === undefined && options . limit !== undefined ) {
118+ // In-flight is unlimited, incoming is limited
119+ if ( inflight . options . where === undefined ) {
120+ // In-flight is loading all data
121+ return true
122+ }
123+ if ( options . where !== undefined ) {
124+ return isWhereSubset ( options . where , inflight . options . where )
125+ }
126+ }
127+
128+ return false
129+ } )
130+
131+ if ( matchingInflight !== undefined ) {
132+ // An in-flight call will load data that covers this request
133+ // Return the same promise so this caller waits for the data to load
134+ // The in-flight promise already handles tracking updates when it completes
135+ return matchingInflight . promise
136+ }
137+
62138 // Not covered by existing data - call underlying loadSubset
63- const resultPromise = loadSubset ( options )
139+ const resultPromise = this . _loadSubset ( options )
64140
65141 // Handle both sync (true) and async (Promise<void>) return values
66142 if ( resultPromise === true ) {
67143 // Sync return - update tracking synchronously
68- updateTracking ( options )
144+ // Clone options before storing to protect against caller mutation
145+ this . updateTracking ( cloneOptions ( options ) )
69146 return true
70147 } else {
71- // Async return - update tracking after promise resolves
72- return resultPromise . then ( ( result ) => {
73- updateTracking ( options )
74- return result
75- } )
148+ // Async return - track the promise and update tracking after it resolves
149+ // Clone options BEFORE entering async context to prevent mutation issues
150+ const clonedOptions = cloneOptions ( options )
151+
152+ // Capture the current generation - this lets us detect if reset() was called
153+ // while this request was in-flight, so we can skip updating tracking state
154+ const capturedGeneration = this . generation
155+
156+ // We need to create a reference to the in-flight entry so we can remove it later
157+ const inflightEntry = {
158+ options : clonedOptions , // Store cloned options for subset matching
159+ promise : resultPromise
160+ . then ( ( result ) => {
161+ // Only update tracking if this request is still from the current generation
162+ // If reset() was called, the generation will have incremented and we should
163+ // not repopulate the state that was just cleared
164+ if ( capturedGeneration === this . generation ) {
165+ // Use the cloned options that we captured before any caller mutations
166+ // This ensures we track exactly what was loaded, not what the caller changed
167+ this . updateTracking ( clonedOptions )
168+ }
169+ return result
170+ } )
171+ . finally ( ( ) => {
172+ // Always remove from in-flight array on completion OR rejection
173+ // This ensures failed requests can be retried instead of being cached forever
174+ const index = this . inflightCalls . indexOf ( inflightEntry )
175+ if ( index !== - 1 ) {
176+ this . inflightCalls . splice ( index , 1 )
177+ }
178+ } ) ,
179+ }
180+
181+ // Store the in-flight entry so concurrent subset calls can wait for it
182+ this . inflightCalls . push ( inflightEntry )
183+ return inflightEntry . promise
76184 }
77185 }
78186
79- function updateTracking ( options : LoadSubsetOptions ) {
187+ /**
188+ * Reset all tracking state.
189+ * Clears the history of loaded predicates and in-flight calls.
190+ * Use this when you want to start fresh, for example after clearing the underlying data store.
191+ *
192+ * Note: Any in-flight requests will still complete, but they will not update the tracking
193+ * state after the reset. This prevents old requests from repopulating cleared state.
194+ */
195+ reset ( ) : void {
196+ this . unlimitedWhere = undefined
197+ this . hasLoadedAllData = false
198+ this . limitedCalls = [ ]
199+ this . inflightCalls = [ ]
200+ // Increment generation to invalidate any in-flight completion handlers
201+ // This ensures requests that were started before reset() don't repopulate the state
202+ this . generation ++
203+ }
204+
205+ private updateTracking ( options : LoadSubsetOptions ) : void {
80206 // Update tracking based on whether this was a limited or unlimited call
81207 if ( options . limit === undefined ) {
82208 // Unlimited call - update combined where predicate
83209 // We ignore orderBy for unlimited calls as mentioned in requirements
84210 if ( options . where === undefined ) {
85211 // No where clause = all data loaded
86- hasLoadedAllData = true
87- unlimitedWhere = undefined
88- } else if ( unlimitedWhere === undefined ) {
89- unlimitedWhere = options . where
212+ this . hasLoadedAllData = true
213+ this . unlimitedWhere = undefined
214+ } else if ( this . unlimitedWhere === undefined ) {
215+ this . unlimitedWhere = options . where
90216 } else {
91- unlimitedWhere = unionWherePredicates ( [ unlimitedWhere , options . where ] )
217+ this . unlimitedWhere = unionWherePredicates ( [
218+ this . unlimitedWhere ,
219+ options . where ,
220+ ] )
92221 }
93222 } else {
94223 // Limited call - add to list for future subset checks
95- limitedCalls . push ( options )
224+ // Options are already cloned by caller to prevent mutation issues
225+ this . limitedCalls . push ( options )
96226 }
97227 }
98228}
229+
230+ /**
231+ * Clones a LoadSubsetOptions object to prevent mutation of stored predicates.
232+ * This is crucial because callers often reuse the same options object and mutate
233+ * properties like limit or where between calls. Without cloning, our stored history
234+ * would reflect the mutated values rather than what was actually loaded.
235+ */
236+ function cloneOptions ( options : LoadSubsetOptions ) : LoadSubsetOptions {
237+ return {
238+ where : options . where ,
239+ orderBy : options . orderBy ,
240+ limit : options . limit ,
241+ // Note: We don't clone subscription as it's not part of predicate matching
242+ }
243+ }
0 commit comments