Skip to content
Merged
5 changes: 5 additions & 0 deletions .changeset/clean-query-ownership.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@tanstack/query-db-collection': patch
---

Clean up empty query ownership state while preserving authoritative empty results and retained-row lifecycle behavior.
46 changes: 36 additions & 10 deletions packages/query-db-collection/src/query.ts
Original file line number Diff line number Diff line change
Expand Up @@ -737,7 +737,8 @@ export function queryCollectionOptions(
// hashedQueryKey → queryKey
const hashToQueryKey = new Map<string, QueryKey>()

// queryKey → Set<RowKey>
// queryKey → Set<RowKey>. Entry presence means ownership is resolved;
// an empty set represents a resolved query that currently owns no rows.
Comment thread
coderabbitai[bot] marked this conversation as resolved.
const queryToRows = new Map<string, Set<string | number>>()

// RowKey → Set<queryKey>
Expand Down Expand Up @@ -775,6 +776,11 @@ export function queryCollectionOptions(
}

const addRowOwners = (rowKey: string | number, owners: Set<string>) => {
if (owners.size === 0) {
rowToQueries.delete(rowKey)
return
}

rowToQueries.set(rowKey, new Set(owners))
owners.forEach((owner) => {
const ownedRows = queryToRows.get(owner) || new Set<string | number>()
Expand All @@ -784,16 +790,16 @@ export function queryCollectionOptions(
}

const removeRowOwner = (rowKey: string | number, hashedQueryKey: string) => {
const owners = rowToQueries.get(rowKey) || new Set<string>()
owners.delete(hashedQueryKey)
rowToQueries.set(rowKey, owners)
const owners = rowToQueries.get(rowKey)
owners?.delete(hashedQueryKey)
if (!owners?.size) {
rowToQueries.delete(rowKey)
}

const ownedRows =
queryToRows.get(hashedQueryKey) || new Set<string | number>()
ownedRows.delete(rowKey)
queryToRows.set(hashedQueryKey, ownedRows)
const ownedRows = queryToRows.get(hashedQueryKey)
ownedRows?.delete(rowKey)

return owners.size === 0
return !owners?.size
}

const removeQueryOwnership = (hashedQueryKey: string) => {
Expand Down Expand Up @@ -1075,6 +1081,7 @@ export function queryCollectionOptions(
`${QUERY_COLLECTION_GC_PREFIX}${hashedQueryKey}`,
)
commit()
queryToRows.delete(hashedQueryKey)
}

const schedulePersistedRetentionExpiry = (
Expand Down Expand Up @@ -1386,6 +1393,13 @@ export function queryCollectionOptions(
const previouslyOwnedRows = shouldUsePersistedBaseline
? new Set(persistedBaseline.keys())
: getHydratedOwnedRowsForQueryBaseline(hashedQueryKey)
// From this point onward the result, including an empty result, is the
// authoritative ownership baseline until this query is cleaned up.
queryToRows.set(
hashedQueryKey,
queryToRows.get(hashedQueryKey) ?? new Set<string | number>(),
)

const newItemsMap = new Map<string | number, any>()
newItemsArray.forEach((item) => {
const key = getKey(item)
Expand Down Expand Up @@ -1746,7 +1760,10 @@ export function queryCollectionOptions(
persistedRetentionTimers.clear()

const allQueryKeys = [...hashToQueryKey.values()]
const allHashedKeys = [...state.observers.keys()]
const allHashedKeys = new Set([
...state.observers.keys(),
...queryToRows.keys(),
])

// Force cleanup all queries (explicit cleanup path)
// This ignores hasListeners and always cleans up
Expand Down Expand Up @@ -2014,6 +2031,15 @@ export function queryCollectionOptions(
}
}

if (typeof process !== `undefined` && process.env.NODE_ENV === `test`) {
Object.defineProperty(enhancedInternalSync, `__getOwnershipMapsForTests`, {
value: () => ({
rowToQueries,
queryToRows,
}),
})
}

// Create write utils using the manual-sync module
const writeUtils = createWriteUtils<any, string | number, any>(
() => writeContext,
Expand Down
Loading
Loading