@tanstack/query-db-collection 1.0.2 → 1.0.4

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,6 +31,35 @@ 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
+
34
63
  // Schema output type inference helper (matches electric.ts pattern)
35
64
  type InferSchemaOutput<T> = T extends StandardSchemaV1
36
65
  ? StandardSchemaV1.InferOutput<T> extends object
@@ -592,6 +621,23 @@ export function queryCollectionOptions(
592
621
  // queryKey → QueryObserver's unsubscribe function
593
622
  const unsubscribes = new Map<string, () => void>()
594
623
 
624
+ // queryKey → reference count (how many loadSubset calls are active)
625
+ // Reference counting for QueryObserver lifecycle management
626
+ // =========================================================
627
+ // Tracks how many live query subscriptions are using each QueryObserver.
628
+ // Multiple live queries with identical predicates share the same QueryObserver for efficiency.
629
+ //
630
+ // Lifecycle:
631
+ // - Increment: when createQueryFromOpts creates or reuses an observer
632
+ // - Decrement: when subscription.unsubscribe() passes predicates to collection._sync.unloadSubset()
633
+ // - Reset: when cleanupQuery() is triggered by TanStack Query's cache GC
634
+ //
635
+ // When refcount reaches 0, unloadSubset():
636
+ // 1. Computes the same queryKey from the predicates
637
+ // 2. Uses existing machinery (queryToRows map) to find rows that query loaded
638
+ // 3. Decrements refcount and GCs rows where count reaches 0
639
+ const queryRefCounts = new Map<string, number>()
640
+
595
641
  // Helper function to add a row to the internal state
596
642
  const addRow = (rowKey: string | number, hashedQueryKey: string) => {
597
643
  const rowToQueriesSet = rowToQueries.get(rowKey) || new Set()
@@ -622,29 +668,44 @@ export function queryCollectionOptions(
622
668
  // Track whether sync has been started
623
669
  let syncStarted = false
624
670
 
625
- const createQueryFromOpts = (
626
- opts: LoadSubsetOptions = {},
627
- queryFunction: typeof queryFn = queryFn
628
- ): true | Promise<void> => {
629
- // Push the predicates down to the queryKey and queryFn
630
- let key: QueryKey
671
+ /**
672
+ * Generate a consistent query key from LoadSubsetOptions.
673
+ * CRITICAL: Must use identical logic in both createQueryFromOpts and unloadSubset
674
+ * so that refcount increment/decrement operations target the same hashedQueryKey.
675
+ * Inconsistent keys would cause refcount leaks and prevent proper cleanup.
676
+ */
677
+ const generateQueryKeyFromOptions = (opts: LoadSubsetOptions): QueryKey => {
631
678
  if (typeof queryKey === `function`) {
632
679
  // Function-based queryKey: use it to build the key from opts
633
- key = queryKey(opts)
680
+ return queryKey(opts)
634
681
  } else if (syncMode === `on-demand`) {
635
682
  // Static queryKey in on-demand mode: automatically append serialized predicates
636
683
  // to create separate cache entries for different predicate combinations
637
684
  const serialized = serializeLoadSubsetOptions(opts)
638
- key = serialized !== undefined ? [...queryKey, serialized] : queryKey
685
+ return serialized !== undefined ? [...queryKey, serialized] : queryKey
639
686
  } else {
640
687
  // Static queryKey in eager mode: use as-is
641
- key = queryKey
688
+ return queryKey
642
689
  }
690
+ }
691
+
692
+ const createQueryFromOpts = (
693
+ opts: LoadSubsetOptions = {},
694
+ queryFunction: typeof queryFn = queryFn
695
+ ): true | Promise<void> => {
696
+ // Generate key using common function
697
+ const key = generateQueryKeyFromOptions(opts)
643
698
  const hashedQueryKey = hashKey(key)
644
699
  const extendedMeta = { ...meta, loadSubsetOptions: opts }
645
700
 
646
701
  if (state.observers.has(hashedQueryKey)) {
647
702
  // We already have a query for this queryKey
703
+ // Increment reference count since another consumer is using this observer
704
+ queryRefCounts.set(
705
+ hashedQueryKey,
706
+ (queryRefCounts.get(hashedQueryKey) || 0) + 1
707
+ )
708
+
648
709
  // Get the current result and return based on its state
649
710
  const observer = state.observers.get(hashedQueryKey)!
650
711
  const currentResult = observer.getCurrentResult()
@@ -703,6 +764,12 @@ export function queryCollectionOptions(
703
764
  hashToQueryKey.set(hashedQueryKey, key)
704
765
  state.observers.set(hashedQueryKey, localObserver)
705
766
 
767
+ // Increment reference count for this query
768
+ queryRefCounts.set(
769
+ hashedQueryKey,
770
+ (queryRefCounts.get(hashedQueryKey) || 0) + 1
771
+ )
772
+
706
773
  // Create a promise that resolves when the query result is first available
707
774
  const readyPromise = new Promise<void>((resolve, reject) => {
708
775
  const unsubscribe = localObserver.subscribe((result) => {
@@ -722,13 +789,6 @@ export function queryCollectionOptions(
722
789
  subscribeToQuery(localObserver, hashedQueryKey)
723
790
  }
724
791
 
725
- // Tell tanstack query to GC the query when the subscription is unsubscribed
726
- // The subscription is unsubscribed when the live query is GCed.
727
- const subscription = opts.subscription
728
- subscription?.once(`unsubscribed`, () => {
729
- queryClient.removeQueries({ queryKey: key, exact: true })
730
- })
731
-
732
792
  return readyPromise
733
793
  }
734
794
 
@@ -845,10 +905,17 @@ export function queryCollectionOptions(
845
905
  hashedQueryKey: string
846
906
  ) => {
847
907
  if (!isSubscribed(hashedQueryKey)) {
848
- const queryKey = hashToQueryKey.get(hashedQueryKey)!
849
- const handleQueryResult = makeQueryResultHandler(queryKey)
908
+ const cachedQueryKey = hashToQueryKey.get(hashedQueryKey)!
909
+ const handleQueryResult = makeQueryResultHandler(cachedQueryKey)
850
910
  const unsubscribeFn = observer.subscribe(handleQueryResult)
851
911
  unsubscribes.set(hashedQueryKey, unsubscribeFn)
912
+
913
+ // Process the current result immediately if available
914
+ // This ensures data is synced when resubscribing to a query with cached data
915
+ const currentResult = observer.getCurrentResult()
916
+ if (currentResult.isSuccess || currentResult.isError) {
917
+ handleQueryResult(currentResult)
918
+ }
852
919
  }
853
920
  }
854
921
 
@@ -898,73 +965,181 @@ export function queryCollectionOptions(
898
965
 
899
966
  // Ensure we process any existing query data (QueryObserver doesn't invoke its callback automatically with initial state)
900
967
  state.observers.forEach((observer, hashedQueryKey) => {
901
- const queryKey = hashToQueryKey.get(hashedQueryKey)!
902
- const handleQueryResult = makeQueryResultHandler(queryKey)
968
+ const cachedQueryKey = hashToQueryKey.get(hashedQueryKey)!
969
+ const handleQueryResult = makeQueryResultHandler(cachedQueryKey)
903
970
  handleQueryResult(observer.getCurrentResult())
904
971
  })
905
972
 
906
- // Subscribe to the query client's cache to handle queries that are GCed by tanstack query
907
- const unsubscribeQueryCache = queryClient
908
- .getQueryCache()
909
- .subscribe((event) => {
910
- const hashedKey = event.query.queryHash
911
- if (event.type === `removed`) {
912
- cleanupQuery(hashedKey)
913
- }
914
- })
915
-
916
- function cleanupQuery(hashedQueryKey: string) {
917
- // Unsubscribe from the query's observer
973
+ /**
974
+ * Perform row-level cleanup and remove all tracking for a query.
975
+ * Callers are responsible for ensuring the query is safe to cleanup.
976
+ */
977
+ const cleanupQueryInternal = (hashedQueryKey: string) => {
918
978
  unsubscribes.get(hashedQueryKey)?.()
979
+ unsubscribes.delete(hashedQueryKey)
919
980
 
920
- // Get all the rows that are in the result of this query
921
981
  const rowKeys = queryToRows.get(hashedQueryKey) ?? new Set()
982
+ const rowsToDelete: Array<any> = []
922
983
 
923
- // Remove the query from these rows
924
984
  rowKeys.forEach((rowKey) => {
925
- const queries = rowToQueries.get(rowKey) // set of queries that reference this row
926
- if (queries && queries.size > 0) {
927
- queries.delete(hashedQueryKey)
928
- if (queries.size === 0) {
929
- // Reference count dropped to 0, we can GC the row
930
- rowToQueries.delete(rowKey)
931
-
932
- if (collection.has(rowKey)) {
933
- begin()
934
- write({ type: `delete`, value: collection.get(rowKey) })
935
- commit()
936
- }
985
+ const queries = rowToQueries.get(rowKey)
986
+
987
+ if (!queries) {
988
+ return
989
+ }
990
+
991
+ queries.delete(hashedQueryKey)
992
+
993
+ if (queries.size === 0) {
994
+ rowToQueries.delete(rowKey)
995
+
996
+ if (collection.has(rowKey)) {
997
+ rowsToDelete.push(collection.get(rowKey))
937
998
  }
938
999
  }
939
1000
  })
940
1001
 
941
- // Remove the query from the internal state
942
- unsubscribes.delete(hashedQueryKey)
1002
+ if (rowsToDelete.length > 0) {
1003
+ begin()
1004
+ rowsToDelete.forEach((row) => {
1005
+ write({ type: `delete`, value: row })
1006
+ })
1007
+ commit()
1008
+ }
1009
+
943
1010
  state.observers.delete(hashedQueryKey)
944
1011
  queryToRows.delete(hashedQueryKey)
945
1012
  hashToQueryKey.delete(hashedQueryKey)
1013
+ queryRefCounts.delete(hashedQueryKey)
1014
+ }
1015
+
1016
+ /**
1017
+ * Attempt to cleanup a query when it appears unused.
1018
+ * Respects refcounts and invalidateQueries cycles via hasListeners().
1019
+ */
1020
+ const cleanupQueryIfIdle = (hashedQueryKey: string) => {
1021
+ const refcount = queryRefCounts.get(hashedQueryKey) || 0
1022
+ const observer = state.observers.get(hashedQueryKey)
1023
+
1024
+ if (refcount <= 0) {
1025
+ // Drop our subscription so hasListeners reflects only active consumers
1026
+ unsubscribes.get(hashedQueryKey)?.()
1027
+ unsubscribes.delete(hashedQueryKey)
1028
+ }
1029
+
1030
+ const hasListeners = observer?.hasListeners() ?? false
1031
+
1032
+ if (hasListeners) {
1033
+ // During invalidateQueries, TanStack Query keeps internal listeners alive.
1034
+ // Leave refcount at 0 but keep observer so it can resubscribe.
1035
+ queryRefCounts.set(hashedQueryKey, 0)
1036
+ return
1037
+ }
1038
+
1039
+ // No listeners means the query is truly idle.
1040
+ // Even if refcount > 0, we treat hasListeners as authoritative to prevent leaks.
1041
+ // This can happen if subscriptions are GC'd without calling unloadSubset.
1042
+ if (refcount > 0) {
1043
+ console.warn(
1044
+ `[cleanupQueryIfIdle] Invariant violation: refcount=${refcount} but no listeners. Cleaning up to prevent leak.`,
1045
+ { hashedQueryKey }
1046
+ )
1047
+ }
1048
+
1049
+ cleanupQueryInternal(hashedQueryKey)
1050
+ }
1051
+
1052
+ /**
1053
+ * Force cleanup used by explicit collection cleanup.
1054
+ * Ignores refcounts/hasListeners and removes everything.
1055
+ */
1056
+ const forceCleanupQuery = (hashedQueryKey: string) => {
1057
+ cleanupQueryInternal(hashedQueryKey)
946
1058
  }
947
1059
 
1060
+ // Subscribe to the query client's cache to handle queries that are GCed by tanstack query
1061
+ const unsubscribeQueryCache = queryClient
1062
+ .getQueryCache()
1063
+ .subscribe((event) => {
1064
+ const hashedKey = event.query.queryHash
1065
+ if (event.type === `removed`) {
1066
+ // Only cleanup if this is OUR query (we track it)
1067
+ if (hashToQueryKey.has(hashedKey)) {
1068
+ // TanStack Query GC'd this query after gcTime expired.
1069
+ // Use the guarded cleanup path to avoid deleting rows for active queries.
1070
+ cleanupQueryIfIdle(hashedKey)
1071
+ }
1072
+ }
1073
+ })
1074
+
948
1075
  const cleanup = async () => {
949
1076
  unsubscribeFromCollectionEvents()
950
1077
  unsubscribeFromQueries()
951
1078
 
952
- const queryKeys = [...hashToQueryKey.values()]
1079
+ const allQueryKeys = [...hashToQueryKey.values()]
1080
+ const allHashedKeys = [...state.observers.keys()]
953
1081
 
954
- hashToQueryKey.clear()
955
- queryToRows.clear()
956
- rowToQueries.clear()
957
- state.observers.clear()
1082
+ // Force cleanup all queries (explicit cleanup path)
1083
+ // This ignores hasListeners and always cleans up
1084
+ for (const hashedKey of allHashedKeys) {
1085
+ forceCleanupQuery(hashedKey)
1086
+ }
1087
+
1088
+ // Unsubscribe from cache events (cleanup already happened above)
958
1089
  unsubscribeQueryCache()
959
1090
 
1091
+ // Remove queries from TanStack Query cache
960
1092
  await Promise.all(
961
- queryKeys.map(async (queryKey) => {
962
- await queryClient.cancelQueries({ queryKey })
963
- queryClient.removeQueries({ queryKey })
1093
+ allQueryKeys.map(async (qKey) => {
1094
+ await queryClient.cancelQueries({ queryKey: qKey, exact: true })
1095
+ queryClient.removeQueries({ queryKey: qKey, exact: true })
964
1096
  })
965
1097
  )
966
1098
  }
967
1099
 
1100
+ /**
1101
+ * Unload a query subset - the subscription-based cleanup path (on-demand mode).
1102
+ *
1103
+ * Called when a live query subscription unsubscribes (via collection._sync.unloadSubset()).
1104
+ *
1105
+ * Flow:
1106
+ * 1. Receives the same predicates that were passed to loadSubset
1107
+ * 2. Computes the queryKey using generateQueryKeyFromOptions (same logic as loadSubset)
1108
+ * 3. Decrements refcount
1109
+ * 4. If refcount reaches 0:
1110
+ * - Checks hasListeners() to detect invalidateQueries cycles
1111
+ * - If hasListeners is true: resets refcount (TanStack Query keeping observer alive)
1112
+ * - If hasListeners is false: calls forceCleanupQuery() to perform row-level GC
1113
+ *
1114
+ * The hasListeners() check prevents premature cleanup during invalidateQueries:
1115
+ * - invalidateQueries causes temporary unsubscribe/resubscribe
1116
+ * - During unsubscribe, our refcount drops to 0
1117
+ * - But observer.hasListeners() is still true (TanStack Query's internal listeners)
1118
+ * - We skip cleanup and reset refcount, allowing resubscribe to succeed
1119
+ *
1120
+ * We don't cancel in-flight requests. Unsubscribing from the observer is sufficient
1121
+ * to prevent late-arriving data from being processed. The request completes and is cached
1122
+ * by TanStack Query, allowing quick remounts to restore data without refetching.
1123
+ */
1124
+ const unloadSubset = (options: LoadSubsetOptions) => {
1125
+ // 1. Same predicates → 2. Same queryKey
1126
+ const key = generateQueryKeyFromOptions(options)
1127
+ const hashedQueryKey = hashKey(key)
1128
+
1129
+ // 3. Decrement refcount
1130
+ const currentCount = queryRefCounts.get(hashedQueryKey) || 0
1131
+ const newCount = currentCount - 1
1132
+
1133
+ // Update refcount
1134
+ if (newCount <= 0) {
1135
+ queryRefCounts.set(hashedQueryKey, 0)
1136
+ cleanupQueryIfIdle(hashedQueryKey)
1137
+ } else {
1138
+ // Still have other references, just decrement
1139
+ queryRefCounts.set(hashedQueryKey, newCount)
1140
+ }
1141
+ }
1142
+
968
1143
  // Create deduplicated loadSubset wrapper for non-eager modes
969
1144
  // This prevents redundant snapshot requests when multiple concurrent
970
1145
  // live queries request overlapping or subset predicates
@@ -973,6 +1148,7 @@ export function queryCollectionOptions(
973
1148
 
974
1149
  return {
975
1150
  loadSubset: loadSubsetDedupe,
1151
+ unloadSubset: syncMode === `eager` ? undefined : unloadSubset,
976
1152
  cleanup,
977
1153
  }
978
1154
  }
@@ -996,9 +1172,9 @@ export function queryCollectionOptions(
996
1172
  * @returns Promise that resolves when the refetch is complete, with QueryObserverResult
997
1173
  */
998
1174
  const refetch: RefetchFn = async (opts) => {
999
- const queryKeys = [...hashToQueryKey.values()]
1000
- const refetchPromises = queryKeys.map((queryKey) => {
1001
- const queryObserver = state.observers.get(hashKey(queryKey))!
1175
+ const allQueryKeys = [...hashToQueryKey.values()]
1176
+ const refetchPromises = allQueryKeys.map((qKey) => {
1177
+ const queryObserver = state.observers.get(hashKey(qKey))!
1002
1178
  return queryObserver.refetch({
1003
1179
  throwOnError: opts?.throwOnError,
1004
1180
  })