@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/dist/cjs/global.d.cts +24 -0
- package/dist/cjs/index.d.cts +2 -1
- package/dist/cjs/query.cjs +99 -42
- package/dist/cjs/query.cjs.map +1 -1
- package/dist/cjs/query.d.cts +0 -24
- package/dist/esm/global.d.ts +24 -0
- package/dist/esm/index.d.ts +2 -1
- package/dist/esm/query.d.ts +0 -24
- package/dist/esm/query.js +99 -42
- package/dist/esm/query.js.map +1 -1
- package/package.json +2 -2
- package/src/global.ts +40 -0
- package/src/index.ts +4 -1
- package/src/query.ts +206 -88
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
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
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
|
-
|
|
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
|
-
|
|
656
|
+
return serialized !== undefined ? [...queryKey, serialized] : queryKey
|
|
668
657
|
} else {
|
|
669
658
|
// Static queryKey in eager mode: use as-is
|
|
670
|
-
|
|
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
|
|
878
|
-
const handleQueryResult = makeQueryResultHandler(
|
|
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
|
|
931
|
-
const handleQueryResult = makeQueryResultHandler(
|
|
939
|
+
const cachedQueryKey = hashToQueryKey.get(hashedQueryKey)!
|
|
940
|
+
const handleQueryResult = makeQueryResultHandler(cachedQueryKey)
|
|
932
941
|
handleQueryResult(observer.getCurrentResult())
|
|
933
942
|
})
|
|
934
943
|
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
|
|
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)
|
|
955
|
-
|
|
956
|
-
|
|
957
|
-
|
|
958
|
-
|
|
959
|
-
|
|
960
|
-
|
|
961
|
-
|
|
962
|
-
|
|
963
|
-
|
|
964
|
-
|
|
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
|
-
|
|
971
|
-
|
|
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
|
|
1050
|
+
const allQueryKeys = [...hashToQueryKey.values()]
|
|
1051
|
+
const allHashedKeys = [...state.observers.keys()]
|
|
982
1052
|
|
|
983
|
-
|
|
984
|
-
|
|
985
|
-
|
|
986
|
-
|
|
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
|
-
|
|
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
|
|
1029
|
-
const refetchPromises =
|
|
1030
|
-
const queryObserver = state.observers.get(hashKey(
|
|
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
|
})
|