@tanstack/query-db-collection 0.3.0 → 1.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/query.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { QueryObserver } from "@tanstack/query-core"
1
+ import { QueryObserver, hashKey } from "@tanstack/query-core"
2
2
  import {
3
3
  GetKeyRequiredError,
4
4
  QueryClientRequiredError,
@@ -6,23 +6,25 @@ import {
6
6
  QueryKeyRequiredError,
7
7
  } from "./errors"
8
8
  import { createWriteUtils } from "./manual-sync"
9
- import type {
10
- QueryClient,
11
- QueryFunctionContext,
12
- QueryKey,
13
- QueryObserverOptions,
14
- QueryObserverResult,
15
- } from "@tanstack/query-core"
16
9
  import type {
17
10
  BaseCollectionConfig,
18
11
  ChangeMessage,
19
12
  CollectionConfig,
20
13
  DeleteMutationFnParams,
21
14
  InsertMutationFnParams,
15
+ LoadSubsetOptions,
22
16
  SyncConfig,
23
17
  UpdateMutationFnParams,
24
18
  UtilsRecord,
25
19
  } from "@tanstack/db"
20
+ import type {
21
+ FetchStatus,
22
+ QueryClient,
23
+ QueryFunctionContext,
24
+ QueryKey,
25
+ QueryObserverOptions,
26
+ QueryObserverResult,
27
+ } from "@tanstack/query-core"
26
28
  import type { StandardSchemaV1 } from "@standard-schema/spec"
27
29
 
28
30
  // Re-export for external use
@@ -42,6 +44,8 @@ type InferSchemaInput<T> = T extends StandardSchemaV1
42
44
  : Record<string, unknown>
43
45
  : Record<string, unknown>
44
46
 
47
+ type TQueryKeyBuilder<TQueryKey> = (opts: LoadSubsetOptions) => TQueryKey
48
+
45
49
  /**
46
50
  * Configuration options for creating a Query Collection
47
51
  * @template T - The explicit type of items stored in the collection
@@ -63,7 +67,7 @@ export interface QueryCollectionConfig<
63
67
  TQueryData = Awaited<ReturnType<TQueryFn>>,
64
68
  > extends BaseCollectionConfig<T, TKey, TSchema> {
65
69
  /** The query key used by TanStack Query to identify this query */
66
- queryKey: TQueryKey
70
+ queryKey: TQueryKey | TQueryKeyBuilder<TQueryKey>
67
71
  /** Function that fetches data from the server. Must return the complete collection state */
68
72
  queryFn: TQueryFn extends (
69
73
  context: QueryFunctionContext<TQueryKey>
@@ -201,9 +205,10 @@ interface QueryCollectionState {
201
205
  lastError: any
202
206
  errorCount: number
203
207
  lastErrorUpdatedAt: number
204
- queryObserver:
205
- | QueryObserver<Array<any>, any, Array<any>, Array<any>, any>
206
- | undefined
208
+ observers: Map<
209
+ string,
210
+ QueryObserver<Array<any>, any, Array<any>, Array<any>, any>
211
+ >
207
212
  }
208
213
 
209
214
  /**
@@ -261,23 +266,40 @@ class QueryCollectionUtilsImpl {
261
266
 
262
267
  // Getters for QueryObserver state
263
268
  public get isFetching() {
264
- return this.state.queryObserver?.getCurrentResult().isFetching ?? false
269
+ // check if any observer is fetching
270
+ return Array.from(this.state.observers.values()).some(
271
+ (observer) => observer.getCurrentResult().isFetching
272
+ )
265
273
  }
266
274
 
267
275
  public get isRefetching() {
268
- return this.state.queryObserver?.getCurrentResult().isRefetching ?? false
276
+ // check if any observer is refetching
277
+ return Array.from(this.state.observers.values()).some(
278
+ (observer) => observer.getCurrentResult().isRefetching
279
+ )
269
280
  }
270
281
 
271
282
  public get isLoading() {
272
- return this.state.queryObserver?.getCurrentResult().isLoading ?? false
283
+ // check if any observer is loading
284
+ return Array.from(this.state.observers.values()).some(
285
+ (observer) => observer.getCurrentResult().isLoading
286
+ )
273
287
  }
274
288
 
275
289
  public get dataUpdatedAt() {
276
- return this.state.queryObserver?.getCurrentResult().dataUpdatedAt ?? 0
290
+ // compute the max dataUpdatedAt of all observers
291
+ return Math.max(
292
+ 0,
293
+ ...Array.from(this.state.observers.values()).map(
294
+ (observer) => observer.getCurrentResult().dataUpdatedAt
295
+ )
296
+ )
277
297
  }
278
298
 
279
- public get fetchStatus() {
280
- return this.state.queryObserver?.getCurrentResult().fetchStatus ?? `idle`
299
+ public get fetchStatus(): Array<FetchStatus> {
300
+ return Array.from(this.state.observers.values()).map(
301
+ (observer) => observer.getCurrentResult().fetchStatus
302
+ )
281
303
  }
282
304
  }
283
305
 
@@ -522,6 +544,9 @@ export function queryCollectionOptions(
522
544
  ...baseCollectionConfig
523
545
  } = config
524
546
 
547
+ // Default to eager sync mode if not provided
548
+ const syncMode = baseCollectionConfig.syncMode ?? `eager`
549
+
525
550
  // Validate required parameters
526
551
 
527
552
  // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
@@ -548,181 +573,394 @@ export function queryCollectionOptions(
548
573
  lastError: undefined as any,
549
574
  errorCount: 0,
550
575
  lastErrorUpdatedAt: 0,
551
- queryObserver: undefined,
576
+ observers: new Map<
577
+ string,
578
+ QueryObserver<Array<any>, any, Array<any>, Array<any>, any>
579
+ >(),
580
+ }
581
+
582
+ // hashedQueryKey → queryKey
583
+ const hashToQueryKey = new Map<string, QueryKey>()
584
+
585
+ // queryKey → Set<RowKey>
586
+ const queryToRows = new Map<string, Set<string | number>>()
587
+
588
+ // RowKey → Set<queryKey>
589
+ const rowToQueries = new Map<string | number, Set<string>>()
590
+
591
+ // queryKey → QueryObserver's unsubscribe function
592
+ const unsubscribes = new Map<string, () => void>()
593
+
594
+ // Helper function to add a row to the internal state
595
+ const addRow = (rowKey: string | number, hashedQueryKey: string) => {
596
+ const rowToQueriesSet = rowToQueries.get(rowKey) || new Set()
597
+ rowToQueriesSet.add(hashedQueryKey)
598
+ rowToQueries.set(rowKey, rowToQueriesSet)
599
+
600
+ const queryToRowsSet = queryToRows.get(hashedQueryKey) || new Set()
601
+ queryToRowsSet.add(rowKey)
602
+ queryToRows.set(hashedQueryKey, queryToRowsSet)
603
+ }
604
+
605
+ // Helper function to remove a row from the internal state
606
+ const removeRow = (rowKey: string | number, hashedQuerKey: string) => {
607
+ const rowToQueriesSet = rowToQueries.get(rowKey) || new Set()
608
+ rowToQueriesSet.delete(hashedQuerKey)
609
+ rowToQueries.set(rowKey, rowToQueriesSet)
610
+
611
+ const queryToRowsSet = queryToRows.get(hashedQuerKey) || new Set()
612
+ queryToRowsSet.delete(rowKey)
613
+ queryToRows.set(hashedQuerKey, queryToRowsSet)
614
+
615
+ return rowToQueriesSet.size === 0
552
616
  }
553
617
 
554
618
  const internalSync: SyncConfig<any>[`sync`] = (params) => {
555
619
  const { begin, write, commit, markReady, collection } = params
556
620
 
557
- const observerOptions: QueryObserverOptions<
558
- Array<any>,
559
- any,
560
- Array<any>,
561
- Array<any>,
562
- any
563
- > = {
564
- queryKey: queryKey,
565
- queryFn: queryFn,
566
- structuralSharing: true,
567
- notifyOnChangeProps: `all`,
568
- // Only include options that are explicitly defined to allow QueryClient defaultOptions to be used
569
- ...(meta !== undefined && { meta }),
570
- ...(enabled !== undefined && { enabled }),
571
- ...(refetchInterval !== undefined && { refetchInterval }),
572
- ...(retry !== undefined && { retry }),
573
- ...(retryDelay !== undefined && { retryDelay }),
574
- ...(staleTime !== undefined && { staleTime }),
575
- }
576
-
577
- const localObserver = new QueryObserver<
578
- Array<any>,
579
- any,
580
- Array<any>,
581
- Array<any>,
582
- any
583
- >(queryClient, observerOptions)
584
-
585
- // Store reference for imperative refetch
586
- state.queryObserver = localObserver
587
-
588
- let isSubscribed = false
589
- let actualUnsubscribeFn: (() => void) | null = null
590
-
591
- type UpdateHandler = Parameters<typeof localObserver.subscribe>[0]
592
- const handleQueryResult: UpdateHandler = (result) => {
593
- if (result.isSuccess) {
594
- // Clear error state
595
- state.lastError = undefined
596
- state.errorCount = 0
597
-
598
- const rawData = result.data
599
- const newItemsArray = select ? select(rawData) : rawData
600
-
601
- if (
602
- !Array.isArray(newItemsArray) ||
603
- newItemsArray.some((item) => typeof item !== `object`)
604
- ) {
605
- const errorMessage = select
606
- ? `@tanstack/query-db-collection: select() must return an array of objects. Got: ${typeof newItemsArray} for queryKey ${JSON.stringify(queryKey)}`
607
- : `@tanstack/query-db-collection: queryFn must return an array of objects. Got: ${typeof newItemsArray} for queryKey ${JSON.stringify(queryKey)}`
608
-
609
- console.error(errorMessage)
610
- return
621
+ // Track whether sync has been started
622
+ let syncStarted = false
623
+
624
+ const createQueryFromOpts = (
625
+ opts: LoadSubsetOptions = {},
626
+ queryFunction: typeof queryFn = queryFn
627
+ ): true | Promise<void> => {
628
+ // Push the predicates down to the queryKey and queryFn
629
+ const key = typeof queryKey === `function` ? queryKey(opts) : queryKey
630
+ const hashedQueryKey = hashKey(key)
631
+ const extendedMeta = { ...meta, loadSubsetOptions: opts }
632
+
633
+ if (state.observers.has(hashedQueryKey)) {
634
+ // We already have a query for this queryKey
635
+ // Get the current result and return based on its state
636
+ const observer = state.observers.get(hashedQueryKey)!
637
+ const currentResult = observer.getCurrentResult()
638
+
639
+ if (currentResult.isSuccess) {
640
+ // Data is already available, return true synchronously
641
+ return true
642
+ } else if (currentResult.isError) {
643
+ // Error already occurred, reject immediately
644
+ return Promise.reject(currentResult.error)
645
+ } else {
646
+ // Query is still loading, wait for the first result
647
+ return new Promise<void>((resolve, reject) => {
648
+ const unsubscribe = observer.subscribe((result) => {
649
+ if (result.isSuccess) {
650
+ unsubscribe()
651
+ resolve()
652
+ } else if (result.isError) {
653
+ unsubscribe()
654
+ reject(result.error)
655
+ }
656
+ })
657
+ })
611
658
  }
659
+ }
660
+
661
+ const observerOptions: QueryObserverOptions<
662
+ Array<any>,
663
+ any,
664
+ Array<any>,
665
+ Array<any>,
666
+ any
667
+ > = {
668
+ queryKey: key,
669
+ queryFn: queryFunction,
670
+ meta: extendedMeta,
671
+ structuralSharing: true,
672
+ notifyOnChangeProps: `all`,
673
+
674
+ // Only include options that are explicitly defined to allow QueryClient defaultOptions to be used
675
+ ...(enabled !== undefined && { enabled }),
676
+ ...(refetchInterval !== undefined && { refetchInterval }),
677
+ ...(retry !== undefined && { retry }),
678
+ ...(retryDelay !== undefined && { retryDelay }),
679
+ ...(staleTime !== undefined && { staleTime }),
680
+ }
612
681
 
613
- const currentSyncedItems: Map<string | number, any> = new Map(
614
- collection._state.syncedData.entries()
615
- )
616
- const newItemsMap = new Map<string | number, any>()
617
- newItemsArray.forEach((item) => {
618
- const key = getKey(item)
619
- newItemsMap.set(key, item)
682
+ const localObserver = new QueryObserver<
683
+ Array<any>,
684
+ any,
685
+ Array<any>,
686
+ Array<any>,
687
+ any
688
+ >(queryClient, observerOptions)
689
+
690
+ hashToQueryKey.set(hashedQueryKey, key)
691
+ state.observers.set(hashedQueryKey, localObserver)
692
+
693
+ // Create a promise that resolves when the query result is first available
694
+ const readyPromise = new Promise<void>((resolve, reject) => {
695
+ const unsubscribe = localObserver.subscribe((result) => {
696
+ if (result.isSuccess) {
697
+ unsubscribe()
698
+ resolve()
699
+ } else if (result.isError) {
700
+ unsubscribe()
701
+ reject(result.error)
702
+ }
620
703
  })
704
+ })
621
705
 
622
- begin()
623
-
624
- // Helper function for shallow equality check of objects
625
- const shallowEqual = (
626
- obj1: Record<string, any>,
627
- obj2: Record<string, any>
628
- ): boolean => {
629
- // Get all keys from both objects
630
- const keys1 = Object.keys(obj1)
631
- const keys2 = Object.keys(obj2)
632
-
633
- // If number of keys is different, objects are not equal
634
- if (keys1.length !== keys2.length) return false
635
-
636
- // Check if all keys in obj1 have the same values in obj2
637
- return keys1.every((key) => {
638
- // Skip comparing functions and complex objects deeply
639
- if (typeof obj1[key] === `function`) return true
640
- return obj1[key] === obj2[key]
641
- })
642
- }
706
+ // If sync has started or there are subscribers to the collection, subscribe to the query straight away
707
+ // This creates the main subscription that handles data updates
708
+ if (syncStarted || collection.subscriberCount > 0) {
709
+ subscribeToQuery(localObserver, hashedQueryKey)
710
+ }
711
+
712
+ // Tell tanstack query to GC the query when the subscription is unsubscribed
713
+ // The subscription is unsubscribed when the live query is GCed.
714
+ const subscription = opts.subscription
715
+ subscription?.once(`unsubscribed`, () => {
716
+ queryClient.removeQueries({ queryKey: key, exact: true })
717
+ })
718
+
719
+ return readyPromise
720
+ }
643
721
 
644
- currentSyncedItems.forEach((oldItem, key) => {
645
- const newItem = newItemsMap.get(key)
646
- if (!newItem) {
647
- write({ type: `delete`, value: oldItem })
648
- } else if (
649
- !shallowEqual(
650
- oldItem as Record<string, any>,
651
- newItem as Record<string, any>
652
- )
722
+ type UpdateHandler = Parameters<QueryObserver[`subscribe`]>[0]
723
+
724
+ const makeQueryResultHandler = (queryKey: QueryKey) => {
725
+ const hashedQueryKey = hashKey(queryKey)
726
+ const handleQueryResult: UpdateHandler = (result) => {
727
+ if (result.isSuccess) {
728
+ // Clear error state
729
+ state.lastError = undefined
730
+ state.errorCount = 0
731
+
732
+ const rawData = result.data
733
+ const newItemsArray = select ? select(rawData) : rawData
734
+
735
+ if (
736
+ !Array.isArray(newItemsArray) ||
737
+ newItemsArray.some((item) => typeof item !== `object`)
653
738
  ) {
654
- // Only update if there are actual differences in the properties
655
- write({ type: `update`, value: newItem })
739
+ const errorMessage = select
740
+ ? `@tanstack/query-db-collection: select() must return an array of objects. Got: ${typeof newItemsArray} for queryKey ${JSON.stringify(queryKey)}`
741
+ : `@tanstack/query-db-collection: queryFn must return an array of objects. Got: ${typeof newItemsArray} for queryKey ${JSON.stringify(queryKey)}`
742
+
743
+ console.error(errorMessage)
744
+ return
656
745
  }
657
- })
658
746
 
659
- newItemsMap.forEach((newItem, key) => {
660
- if (!currentSyncedItems.has(key)) {
661
- write({ type: `insert`, value: newItem })
747
+ const currentSyncedItems: Map<string | number, any> = new Map(
748
+ collection._state.syncedData.entries()
749
+ )
750
+ const newItemsMap = new Map<string | number, any>()
751
+ newItemsArray.forEach((item) => {
752
+ const key = getKey(item)
753
+ newItemsMap.set(key, item)
754
+ })
755
+
756
+ begin()
757
+
758
+ // Helper function for shallow equality check of objects
759
+ const shallowEqual = (
760
+ obj1: Record<string, any>,
761
+ obj2: Record<string, any>
762
+ ): boolean => {
763
+ // Get all keys from both objects
764
+ const keys1 = Object.keys(obj1)
765
+ const keys2 = Object.keys(obj2)
766
+
767
+ // If number of keys is different, objects are not equal
768
+ if (keys1.length !== keys2.length) return false
769
+
770
+ // Check if all keys in obj1 have the same values in obj2
771
+ return keys1.every((key) => {
772
+ // Skip comparing functions and complex objects deeply
773
+ if (typeof obj1[key] === `function`) return true
774
+ return obj1[key] === obj2[key]
775
+ })
662
776
  }
663
- })
664
777
 
665
- commit()
778
+ currentSyncedItems.forEach((oldItem, key) => {
779
+ const newItem = newItemsMap.get(key)
780
+ if (!newItem) {
781
+ const needToRemove = removeRow(key, hashedQueryKey) // returns true if the row is no longer referenced by any queries
782
+ if (needToRemove) {
783
+ write({ type: `delete`, value: oldItem })
784
+ }
785
+ } else if (
786
+ !shallowEqual(
787
+ oldItem as Record<string, any>,
788
+ newItem as Record<string, any>
789
+ )
790
+ ) {
791
+ // Only update if there are actual differences in the properties
792
+ write({ type: `update`, value: newItem })
793
+ }
794
+ })
666
795
 
667
- // Mark collection as ready after first successful query result
668
- markReady()
669
- } else if (result.isError) {
670
- if (result.errorUpdatedAt !== state.lastErrorUpdatedAt) {
671
- state.lastError = result.error
672
- state.errorCount++
673
- state.lastErrorUpdatedAt = result.errorUpdatedAt
674
- }
796
+ newItemsMap.forEach((newItem, key) => {
797
+ addRow(key, hashedQueryKey)
798
+ if (!currentSyncedItems.has(key)) {
799
+ write({ type: `insert`, value: newItem })
800
+ }
801
+ })
675
802
 
676
- console.error(
677
- `[QueryCollection] Error observing query ${String(queryKey)}:`,
678
- result.error
679
- )
803
+ commit()
680
804
 
681
- // Mark collection as ready even on error to avoid blocking apps
682
- markReady()
805
+ // Mark collection as ready after first successful query result
806
+ markReady()
807
+ } else if (result.isError) {
808
+ if (result.errorUpdatedAt !== state.lastErrorUpdatedAt) {
809
+ state.lastError = result.error
810
+ state.errorCount++
811
+ state.lastErrorUpdatedAt = result.errorUpdatedAt
812
+ }
813
+
814
+ console.error(
815
+ `[QueryCollection] Error observing query ${String(queryKey)}:`,
816
+ result.error
817
+ )
818
+
819
+ // Mark collection as ready even on error to avoid blocking apps
820
+ markReady()
821
+ }
683
822
  }
823
+ return handleQueryResult
684
824
  }
685
825
 
686
- const subscribeToQuery = () => {
687
- if (!isSubscribed) {
688
- actualUnsubscribeFn = localObserver.subscribe(handleQueryResult)
689
- isSubscribed = true
690
- }
826
+ const isSubscribed = (hashedQueryKey: string) => {
827
+ return unsubscribes.has(hashedQueryKey)
691
828
  }
692
829
 
693
- const unsubscribeFromQuery = () => {
694
- if (isSubscribed && actualUnsubscribeFn) {
695
- actualUnsubscribeFn()
696
- actualUnsubscribeFn = null
697
- isSubscribed = false
830
+ const subscribeToQuery = (
831
+ observer: QueryObserver<Array<any>, any, Array<any>, Array<any>, any>,
832
+ hashedQueryKey: string
833
+ ) => {
834
+ if (!isSubscribed(hashedQueryKey)) {
835
+ const queryKey = hashToQueryKey.get(hashedQueryKey)!
836
+ const handleQueryResult = makeQueryResultHandler(queryKey)
837
+ const unsubscribeFn = observer.subscribe(handleQueryResult)
838
+ unsubscribes.set(hashedQueryKey, unsubscribeFn)
698
839
  }
699
840
  }
700
841
 
701
- // Always subscribe when sync starts (this could be from preload(), startSync config, or first subscriber)
702
- // We'll dynamically unsubscribe/resubscribe based on subscriber count to maintain staleTime behavior
703
- subscribeToQuery()
842
+ const subscribeToQueries = () => {
843
+ state.observers.forEach(subscribeToQuery)
844
+ }
845
+
846
+ const unsubscribeFromQueries = () => {
847
+ unsubscribes.forEach((unsubscribeFn) => {
848
+ unsubscribeFn()
849
+ })
850
+ unsubscribes.clear()
851
+ }
852
+
853
+ // Mark that sync has started
854
+ syncStarted = true
704
855
 
705
856
  // Set up event listener for subscriber changes
706
857
  const unsubscribeFromCollectionEvents = collection.on(
707
858
  `subscribers:change`,
708
859
  ({ subscriberCount }) => {
709
860
  if (subscriberCount > 0) {
710
- subscribeToQuery()
861
+ subscribeToQueries()
711
862
  } else if (subscriberCount === 0) {
712
- unsubscribeFromQuery()
863
+ unsubscribeFromQueries()
713
864
  }
714
865
  }
715
866
  )
716
867
 
717
- // Ensure we process any existing query data (QueryObserver doesn't invoke its callback automatically with initial
718
- // state)
719
- handleQueryResult(localObserver.getCurrentResult())
868
+ // If syncMode is eager, create the initial query without any predicates
869
+ if (syncMode === `eager`) {
870
+ // Catch any errors to prevent unhandled rejections
871
+ const initialResult = createQueryFromOpts({})
872
+ if (initialResult instanceof Promise) {
873
+ initialResult.catch(() => {
874
+ // Errors are already handled by the query result handler
875
+ })
876
+ }
877
+ } else {
878
+ // In on-demand mode, mark ready immediately since there's no initial query
879
+ markReady()
880
+ }
881
+
882
+ // Always subscribe when sync starts (this could be from preload(), startSync config, or first subscriber)
883
+ // We'll dynamically unsubscribe/resubscribe based on subscriber count to maintain staleTime behavior
884
+ subscribeToQueries()
885
+
886
+ // Ensure we process any existing query data (QueryObserver doesn't invoke its callback automatically with initial state)
887
+ state.observers.forEach((observer, hashedQueryKey) => {
888
+ const queryKey = hashToQueryKey.get(hashedQueryKey)!
889
+ const handleQueryResult = makeQueryResultHandler(queryKey)
890
+ handleQueryResult(observer.getCurrentResult())
891
+ })
892
+
893
+ // Subscribe to the query client's cache to handle queries that are GCed by tanstack query
894
+ const unsubscribeQueryCache = queryClient
895
+ .getQueryCache()
896
+ .subscribe((event) => {
897
+ const hashedKey = event.query.queryHash
898
+ if (event.type === `removed`) {
899
+ cleanupQuery(hashedKey)
900
+ }
901
+ })
902
+
903
+ function cleanupQuery(hashedQueryKey: string) {
904
+ // Unsubscribe from the query's observer
905
+ unsubscribes.get(hashedQueryKey)?.()
906
+
907
+ // Get all the rows that are in the result of this query
908
+ const rowKeys = queryToRows.get(hashedQueryKey) ?? new Set()
909
+
910
+ // Remove the query from these rows
911
+ rowKeys.forEach((rowKey) => {
912
+ const queries = rowToQueries.get(rowKey) // set of queries that reference this row
913
+ if (queries && queries.size > 0) {
914
+ queries.delete(hashedQueryKey)
915
+ if (queries.size === 0) {
916
+ // Reference count dropped to 0, we can GC the row
917
+ rowToQueries.delete(rowKey)
918
+
919
+ if (collection.has(rowKey)) {
920
+ begin()
921
+ write({ type: `delete`, value: collection.get(rowKey) })
922
+ commit()
923
+ }
924
+ }
925
+ }
926
+ })
720
927
 
721
- return async () => {
928
+ // Remove the query from the internal state
929
+ unsubscribes.delete(hashedQueryKey)
930
+ state.observers.delete(hashedQueryKey)
931
+ queryToRows.delete(hashedQueryKey)
932
+ hashToQueryKey.delete(hashedQueryKey)
933
+ }
934
+
935
+ const cleanup = async () => {
722
936
  unsubscribeFromCollectionEvents()
723
- unsubscribeFromQuery()
724
- await queryClient.cancelQueries({ queryKey })
725
- queryClient.removeQueries({ queryKey })
937
+ unsubscribeFromQueries()
938
+
939
+ const queryKeys = [...hashToQueryKey.values()]
940
+
941
+ hashToQueryKey.clear()
942
+ queryToRows.clear()
943
+ rowToQueries.clear()
944
+ state.observers.clear()
945
+ unsubscribeQueryCache()
946
+
947
+ await Promise.all(
948
+ queryKeys.map(async (queryKey) => {
949
+ await queryClient.cancelQueries({ queryKey })
950
+ queryClient.removeQueries({ queryKey })
951
+ })
952
+ )
953
+ }
954
+
955
+ // Create deduplicated loadSubset wrapper for non-eager modes
956
+ // This prevents redundant snapshot requests when multiple concurrent
957
+ // live queries request overlapping or subset predicates
958
+ const loadSubsetDedupe =
959
+ syncMode === `eager` ? undefined : createQueryFromOpts
960
+
961
+ return {
962
+ loadSubset: loadSubsetDedupe,
963
+ cleanup,
726
964
  }
727
965
  }
728
966
 
@@ -745,15 +983,15 @@ export function queryCollectionOptions(
745
983
  * @returns Promise that resolves when the refetch is complete, with QueryObserverResult
746
984
  */
747
985
  const refetch: RefetchFn = async (opts) => {
748
- // Observer is created when sync starts. If never synced, nothing to refetch.
749
-
750
- if (!state.queryObserver) {
751
- return
752
- }
753
- // Return the QueryObserverResult for users to inspect
754
- return state.queryObserver.refetch({
755
- throwOnError: opts?.throwOnError,
986
+ const queryKeys = [...hashToQueryKey.values()]
987
+ const refetchPromises = queryKeys.map((queryKey) => {
988
+ const queryObserver = state.observers.get(hashKey(queryKey))!
989
+ return queryObserver.refetch({
990
+ throwOnError: opts?.throwOnError,
991
+ })
756
992
  })
993
+
994
+ await Promise.all(refetchPromises)
757
995
  }
758
996
 
759
997
  // Create write context for manual write operations
@@ -840,6 +1078,7 @@ export function queryCollectionOptions(
840
1078
  return {
841
1079
  ...baseCollectionConfig,
842
1080
  getKey,
1081
+ syncMode,
843
1082
  sync: { sync: enhancedInternalSync },
844
1083
  onInsert: wrappedOnInsert,
845
1084
  onUpdate: wrappedOnUpdate,