@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/dist/cjs/index.d.cts +1 -1
- package/dist/cjs/manual-sync.cjs +1 -1
- package/dist/cjs/manual-sync.cjs.map +1 -1
- package/dist/cjs/query.cjs +99 -42
- package/dist/cjs/query.cjs.map +1 -1
- package/dist/cjs/query.d.cts +24 -0
- package/dist/esm/index.d.ts +1 -1
- package/dist/esm/manual-sync.js +1 -1
- package/dist/esm/manual-sync.js.map +1 -1
- package/dist/esm/query.d.ts +24 -0
- package/dist/esm/query.js +99 -42
- package/dist/esm/query.js.map +1 -1
- package/package.json +2 -2
- package/src/index.ts +1 -0
- package/src/manual-sync.ts +1 -1
- package/src/query.ts +235 -59
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
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
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
|
-
|
|
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
|
-
|
|
685
|
+
return serialized !== undefined ? [...queryKey, serialized] : queryKey
|
|
639
686
|
} else {
|
|
640
687
|
// Static queryKey in eager mode: use as-is
|
|
641
|
-
|
|
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
|
|
849
|
-
const handleQueryResult = makeQueryResultHandler(
|
|
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
|
|
902
|
-
const handleQueryResult = makeQueryResultHandler(
|
|
968
|
+
const cachedQueryKey = hashToQueryKey.get(hashedQueryKey)!
|
|
969
|
+
const handleQueryResult = makeQueryResultHandler(cachedQueryKey)
|
|
903
970
|
handleQueryResult(observer.getCurrentResult())
|
|
904
971
|
})
|
|
905
972
|
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
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)
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
|
|
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
|
-
|
|
942
|
-
|
|
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
|
|
1079
|
+
const allQueryKeys = [...hashToQueryKey.values()]
|
|
1080
|
+
const allHashedKeys = [...state.observers.keys()]
|
|
953
1081
|
|
|
954
|
-
|
|
955
|
-
|
|
956
|
-
|
|
957
|
-
|
|
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
|
-
|
|
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
|
|
1000
|
-
const refetchPromises =
|
|
1001
|
-
const queryObserver = state.observers.get(hashKey(
|
|
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
|
})
|