@tanstack/query-db-collection 1.0.3 → 1.0.5

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
@@ -31,35 +31,6 @@ import type { StandardSchemaV1 } from "@standard-schema/spec"
31
31
  // Re-export for external use
32
32
  export type { SyncOperation } from "./manual-sync"
33
33
 
34
- /**
35
- * Base type for Query Collection meta properties.
36
- * Users can extend this type when augmenting the @tanstack/query-core module
37
- * to add their own custom properties while preserving loadSubsetOptions.
38
- *
39
- * @example
40
- * ```typescript
41
- * declare module "@tanstack/query-core" {
42
- * interface Register {
43
- * queryMeta: QueryCollectionMeta & {
44
- * myCustomProperty: string
45
- * }
46
- * }
47
- * }
48
- * ```
49
- */
50
- export type QueryCollectionMeta = Record<string, unknown> & {
51
- loadSubsetOptions: LoadSubsetOptions
52
- }
53
-
54
- // Module augmentation to extend TanStack Query's Register interface
55
- // This ensures that ctx.meta always includes loadSubsetOptions
56
- // We extend Record<string, unknown> to preserve the ability to add other meta properties
57
- declare module "@tanstack/query-core" {
58
- interface Register {
59
- queryMeta: QueryCollectionMeta
60
- }
61
- }
62
-
63
34
  // Schema output type inference helper (matches electric.ts pattern)
64
35
  type InferSchemaOutput<T> = T extends StandardSchemaV1
65
36
  ? StandardSchemaV1.InferOutput<T> extends object
@@ -621,6 +592,23 @@ export function queryCollectionOptions(
621
592
  // queryKey → QueryObserver's unsubscribe function
622
593
  const unsubscribes = new Map<string, () => void>()
623
594
 
595
+ // queryKey → reference count (how many loadSubset calls are active)
596
+ // Reference counting for QueryObserver lifecycle management
597
+ // =========================================================
598
+ // Tracks how many live query subscriptions are using each QueryObserver.
599
+ // Multiple live queries with identical predicates share the same QueryObserver for efficiency.
600
+ //
601
+ // Lifecycle:
602
+ // - Increment: when createQueryFromOpts creates or reuses an observer
603
+ // - Decrement: when subscription.unsubscribe() passes predicates to collection._sync.unloadSubset()
604
+ // - Reset: when cleanupQuery() is triggered by TanStack Query's cache GC
605
+ //
606
+ // When refcount reaches 0, unloadSubset():
607
+ // 1. Computes the same queryKey from the predicates
608
+ // 2. Uses existing machinery (queryToRows map) to find rows that query loaded
609
+ // 3. Decrements refcount and GCs rows where count reaches 0
610
+ const queryRefCounts = new Map<string, number>()
611
+
624
612
  // Helper function to add a row to the internal state
625
613
  const addRow = (rowKey: string | number, hashedQueryKey: string) => {
626
614
  const rowToQueriesSet = rowToQueries.get(rowKey) || new Set()
@@ -651,29 +639,44 @@ export function queryCollectionOptions(
651
639
  // Track whether sync has been started
652
640
  let syncStarted = false
653
641
 
654
- const createQueryFromOpts = (
655
- opts: LoadSubsetOptions = {},
656
- queryFunction: typeof queryFn = queryFn
657
- ): true | Promise<void> => {
658
- // Push the predicates down to the queryKey and queryFn
659
- let key: QueryKey
642
+ /**
643
+ * Generate a consistent query key from LoadSubsetOptions.
644
+ * CRITICAL: Must use identical logic in both createQueryFromOpts and unloadSubset
645
+ * so that refcount increment/decrement operations target the same hashedQueryKey.
646
+ * Inconsistent keys would cause refcount leaks and prevent proper cleanup.
647
+ */
648
+ const generateQueryKeyFromOptions = (opts: LoadSubsetOptions): QueryKey => {
660
649
  if (typeof queryKey === `function`) {
661
650
  // Function-based queryKey: use it to build the key from opts
662
- key = queryKey(opts)
651
+ return queryKey(opts)
663
652
  } else if (syncMode === `on-demand`) {
664
653
  // Static queryKey in on-demand mode: automatically append serialized predicates
665
654
  // to create separate cache entries for different predicate combinations
666
655
  const serialized = serializeLoadSubsetOptions(opts)
667
- key = serialized !== undefined ? [...queryKey, serialized] : queryKey
656
+ return serialized !== undefined ? [...queryKey, serialized] : queryKey
668
657
  } else {
669
658
  // Static queryKey in eager mode: use as-is
670
- key = queryKey
659
+ return queryKey
671
660
  }
661
+ }
662
+
663
+ const createQueryFromOpts = (
664
+ opts: LoadSubsetOptions = {},
665
+ queryFunction: typeof queryFn = queryFn
666
+ ): true | Promise<void> => {
667
+ // Generate key using common function
668
+ const key = generateQueryKeyFromOptions(opts)
672
669
  const hashedQueryKey = hashKey(key)
673
670
  const extendedMeta = { ...meta, loadSubsetOptions: opts }
674
671
 
675
672
  if (state.observers.has(hashedQueryKey)) {
676
673
  // We already have a query for this queryKey
674
+ // Increment reference count since another consumer is using this observer
675
+ queryRefCounts.set(
676
+ hashedQueryKey,
677
+ (queryRefCounts.get(hashedQueryKey) || 0) + 1
678
+ )
679
+
677
680
  // Get the current result and return based on its state
678
681
  const observer = state.observers.get(hashedQueryKey)!
679
682
  const currentResult = observer.getCurrentResult()
@@ -732,6 +735,12 @@ export function queryCollectionOptions(
732
735
  hashToQueryKey.set(hashedQueryKey, key)
733
736
  state.observers.set(hashedQueryKey, localObserver)
734
737
 
738
+ // Increment reference count for this query
739
+ queryRefCounts.set(
740
+ hashedQueryKey,
741
+ (queryRefCounts.get(hashedQueryKey) || 0) + 1
742
+ )
743
+
735
744
  // Create a promise that resolves when the query result is first available
736
745
  const readyPromise = new Promise<void>((resolve, reject) => {
737
746
  const unsubscribe = localObserver.subscribe((result) => {
@@ -751,13 +760,6 @@ export function queryCollectionOptions(
751
760
  subscribeToQuery(localObserver, hashedQueryKey)
752
761
  }
753
762
 
754
- // Tell tanstack query to GC the query when the subscription is unsubscribed
755
- // The subscription is unsubscribed when the live query is GCed.
756
- const subscription = opts.subscription
757
- subscription?.once(`unsubscribed`, () => {
758
- queryClient.removeQueries({ queryKey: key, exact: true })
759
- })
760
-
761
763
  return readyPromise
762
764
  }
763
765
 
@@ -874,10 +876,17 @@ export function queryCollectionOptions(
874
876
  hashedQueryKey: string
875
877
  ) => {
876
878
  if (!isSubscribed(hashedQueryKey)) {
877
- const queryKey = hashToQueryKey.get(hashedQueryKey)!
878
- const handleQueryResult = makeQueryResultHandler(queryKey)
879
+ const cachedQueryKey = hashToQueryKey.get(hashedQueryKey)!
880
+ const handleQueryResult = makeQueryResultHandler(cachedQueryKey)
879
881
  const unsubscribeFn = observer.subscribe(handleQueryResult)
880
882
  unsubscribes.set(hashedQueryKey, unsubscribeFn)
883
+
884
+ // Process the current result immediately if available
885
+ // This ensures data is synced when resubscribing to a query with cached data
886
+ const currentResult = observer.getCurrentResult()
887
+ if (currentResult.isSuccess || currentResult.isError) {
888
+ handleQueryResult(currentResult)
889
+ }
881
890
  }
882
891
  }
883
892
 
@@ -927,73 +936,181 @@ export function queryCollectionOptions(
927
936
 
928
937
  // Ensure we process any existing query data (QueryObserver doesn't invoke its callback automatically with initial state)
929
938
  state.observers.forEach((observer, hashedQueryKey) => {
930
- const queryKey = hashToQueryKey.get(hashedQueryKey)!
931
- const handleQueryResult = makeQueryResultHandler(queryKey)
939
+ const cachedQueryKey = hashToQueryKey.get(hashedQueryKey)!
940
+ const handleQueryResult = makeQueryResultHandler(cachedQueryKey)
932
941
  handleQueryResult(observer.getCurrentResult())
933
942
  })
934
943
 
935
- // Subscribe to the query client's cache to handle queries that are GCed by tanstack query
936
- const unsubscribeQueryCache = queryClient
937
- .getQueryCache()
938
- .subscribe((event) => {
939
- const hashedKey = event.query.queryHash
940
- if (event.type === `removed`) {
941
- cleanupQuery(hashedKey)
942
- }
943
- })
944
-
945
- function cleanupQuery(hashedQueryKey: string) {
946
- // Unsubscribe from the query's observer
944
+ /**
945
+ * Perform row-level cleanup and remove all tracking for a query.
946
+ * Callers are responsible for ensuring the query is safe to cleanup.
947
+ */
948
+ const cleanupQueryInternal = (hashedQueryKey: string) => {
947
949
  unsubscribes.get(hashedQueryKey)?.()
950
+ unsubscribes.delete(hashedQueryKey)
948
951
 
949
- // Get all the rows that are in the result of this query
950
952
  const rowKeys = queryToRows.get(hashedQueryKey) ?? new Set()
953
+ const rowsToDelete: Array<any> = []
951
954
 
952
- // Remove the query from these rows
953
955
  rowKeys.forEach((rowKey) => {
954
- const queries = rowToQueries.get(rowKey) // set of queries that reference this row
955
- if (queries && queries.size > 0) {
956
- queries.delete(hashedQueryKey)
957
- if (queries.size === 0) {
958
- // Reference count dropped to 0, we can GC the row
959
- rowToQueries.delete(rowKey)
960
-
961
- if (collection.has(rowKey)) {
962
- begin()
963
- write({ type: `delete`, value: collection.get(rowKey) })
964
- commit()
965
- }
956
+ const queries = rowToQueries.get(rowKey)
957
+
958
+ if (!queries) {
959
+ return
960
+ }
961
+
962
+ queries.delete(hashedQueryKey)
963
+
964
+ if (queries.size === 0) {
965
+ rowToQueries.delete(rowKey)
966
+
967
+ if (collection.has(rowKey)) {
968
+ rowsToDelete.push(collection.get(rowKey))
966
969
  }
967
970
  }
968
971
  })
969
972
 
970
- // Remove the query from the internal state
971
- unsubscribes.delete(hashedQueryKey)
973
+ if (rowsToDelete.length > 0) {
974
+ begin()
975
+ rowsToDelete.forEach((row) => {
976
+ write({ type: `delete`, value: row })
977
+ })
978
+ commit()
979
+ }
980
+
972
981
  state.observers.delete(hashedQueryKey)
973
982
  queryToRows.delete(hashedQueryKey)
974
983
  hashToQueryKey.delete(hashedQueryKey)
984
+ queryRefCounts.delete(hashedQueryKey)
985
+ }
986
+
987
+ /**
988
+ * Attempt to cleanup a query when it appears unused.
989
+ * Respects refcounts and invalidateQueries cycles via hasListeners().
990
+ */
991
+ const cleanupQueryIfIdle = (hashedQueryKey: string) => {
992
+ const refcount = queryRefCounts.get(hashedQueryKey) || 0
993
+ const observer = state.observers.get(hashedQueryKey)
994
+
995
+ if (refcount <= 0) {
996
+ // Drop our subscription so hasListeners reflects only active consumers
997
+ unsubscribes.get(hashedQueryKey)?.()
998
+ unsubscribes.delete(hashedQueryKey)
999
+ }
1000
+
1001
+ const hasListeners = observer?.hasListeners() ?? false
1002
+
1003
+ if (hasListeners) {
1004
+ // During invalidateQueries, TanStack Query keeps internal listeners alive.
1005
+ // Leave refcount at 0 but keep observer so it can resubscribe.
1006
+ queryRefCounts.set(hashedQueryKey, 0)
1007
+ return
1008
+ }
1009
+
1010
+ // No listeners means the query is truly idle.
1011
+ // Even if refcount > 0, we treat hasListeners as authoritative to prevent leaks.
1012
+ // This can happen if subscriptions are GC'd without calling unloadSubset.
1013
+ if (refcount > 0) {
1014
+ console.warn(
1015
+ `[cleanupQueryIfIdle] Invariant violation: refcount=${refcount} but no listeners. Cleaning up to prevent leak.`,
1016
+ { hashedQueryKey }
1017
+ )
1018
+ }
1019
+
1020
+ cleanupQueryInternal(hashedQueryKey)
1021
+ }
1022
+
1023
+ /**
1024
+ * Force cleanup used by explicit collection cleanup.
1025
+ * Ignores refcounts/hasListeners and removes everything.
1026
+ */
1027
+ const forceCleanupQuery = (hashedQueryKey: string) => {
1028
+ cleanupQueryInternal(hashedQueryKey)
975
1029
  }
976
1030
 
1031
+ // Subscribe to the query client's cache to handle queries that are GCed by tanstack query
1032
+ const unsubscribeQueryCache = queryClient
1033
+ .getQueryCache()
1034
+ .subscribe((event) => {
1035
+ const hashedKey = event.query.queryHash
1036
+ if (event.type === `removed`) {
1037
+ // Only cleanup if this is OUR query (we track it)
1038
+ if (hashToQueryKey.has(hashedKey)) {
1039
+ // TanStack Query GC'd this query after gcTime expired.
1040
+ // Use the guarded cleanup path to avoid deleting rows for active queries.
1041
+ cleanupQueryIfIdle(hashedKey)
1042
+ }
1043
+ }
1044
+ })
1045
+
977
1046
  const cleanup = async () => {
978
1047
  unsubscribeFromCollectionEvents()
979
1048
  unsubscribeFromQueries()
980
1049
 
981
- const queryKeys = [...hashToQueryKey.values()]
1050
+ const allQueryKeys = [...hashToQueryKey.values()]
1051
+ const allHashedKeys = [...state.observers.keys()]
982
1052
 
983
- hashToQueryKey.clear()
984
- queryToRows.clear()
985
- rowToQueries.clear()
986
- state.observers.clear()
1053
+ // Force cleanup all queries (explicit cleanup path)
1054
+ // This ignores hasListeners and always cleans up
1055
+ for (const hashedKey of allHashedKeys) {
1056
+ forceCleanupQuery(hashedKey)
1057
+ }
1058
+
1059
+ // Unsubscribe from cache events (cleanup already happened above)
987
1060
  unsubscribeQueryCache()
988
1061
 
1062
+ // Remove queries from TanStack Query cache
989
1063
  await Promise.all(
990
- queryKeys.map(async (queryKey) => {
991
- await queryClient.cancelQueries({ queryKey })
992
- queryClient.removeQueries({ queryKey })
1064
+ allQueryKeys.map(async (qKey) => {
1065
+ await queryClient.cancelQueries({ queryKey: qKey, exact: true })
1066
+ queryClient.removeQueries({ queryKey: qKey, exact: true })
993
1067
  })
994
1068
  )
995
1069
  }
996
1070
 
1071
+ /**
1072
+ * Unload a query subset - the subscription-based cleanup path (on-demand mode).
1073
+ *
1074
+ * Called when a live query subscription unsubscribes (via collection._sync.unloadSubset()).
1075
+ *
1076
+ * Flow:
1077
+ * 1. Receives the same predicates that were passed to loadSubset
1078
+ * 2. Computes the queryKey using generateQueryKeyFromOptions (same logic as loadSubset)
1079
+ * 3. Decrements refcount
1080
+ * 4. If refcount reaches 0:
1081
+ * - Checks hasListeners() to detect invalidateQueries cycles
1082
+ * - If hasListeners is true: resets refcount (TanStack Query keeping observer alive)
1083
+ * - If hasListeners is false: calls forceCleanupQuery() to perform row-level GC
1084
+ *
1085
+ * The hasListeners() check prevents premature cleanup during invalidateQueries:
1086
+ * - invalidateQueries causes temporary unsubscribe/resubscribe
1087
+ * - During unsubscribe, our refcount drops to 0
1088
+ * - But observer.hasListeners() is still true (TanStack Query's internal listeners)
1089
+ * - We skip cleanup and reset refcount, allowing resubscribe to succeed
1090
+ *
1091
+ * We don't cancel in-flight requests. Unsubscribing from the observer is sufficient
1092
+ * to prevent late-arriving data from being processed. The request completes and is cached
1093
+ * by TanStack Query, allowing quick remounts to restore data without refetching.
1094
+ */
1095
+ const unloadSubset = (options: LoadSubsetOptions) => {
1096
+ // 1. Same predicates → 2. Same queryKey
1097
+ const key = generateQueryKeyFromOptions(options)
1098
+ const hashedQueryKey = hashKey(key)
1099
+
1100
+ // 3. Decrement refcount
1101
+ const currentCount = queryRefCounts.get(hashedQueryKey) || 0
1102
+ const newCount = currentCount - 1
1103
+
1104
+ // Update refcount
1105
+ if (newCount <= 0) {
1106
+ queryRefCounts.set(hashedQueryKey, 0)
1107
+ cleanupQueryIfIdle(hashedQueryKey)
1108
+ } else {
1109
+ // Still have other references, just decrement
1110
+ queryRefCounts.set(hashedQueryKey, newCount)
1111
+ }
1112
+ }
1113
+
997
1114
  // Create deduplicated loadSubset wrapper for non-eager modes
998
1115
  // This prevents redundant snapshot requests when multiple concurrent
999
1116
  // live queries request overlapping or subset predicates
@@ -1002,6 +1119,7 @@ export function queryCollectionOptions(
1002
1119
 
1003
1120
  return {
1004
1121
  loadSubset: loadSubsetDedupe,
1122
+ unloadSubset: syncMode === `eager` ? undefined : unloadSubset,
1005
1123
  cleanup,
1006
1124
  }
1007
1125
  }
@@ -1025,9 +1143,9 @@ export function queryCollectionOptions(
1025
1143
  * @returns Promise that resolves when the refetch is complete, with QueryObserverResult
1026
1144
  */
1027
1145
  const refetch: RefetchFn = async (opts) => {
1028
- const queryKeys = [...hashToQueryKey.values()]
1029
- const refetchPromises = queryKeys.map((queryKey) => {
1030
- const queryObserver = state.observers.get(hashKey(queryKey))!
1146
+ const allQueryKeys = [...hashToQueryKey.values()]
1147
+ const refetchPromises = allQueryKeys.map((qKey) => {
1148
+ const queryObserver = state.observers.get(hashKey(qKey))!
1031
1149
  return queryObserver.refetch({
1032
1150
  throwOnError: opts?.throwOnError,
1033
1151
  })