diff --git a/.changeset/small-mice-fold.md b/.changeset/small-mice-fold.md new file mode 100644 index 000000000..61b875e86 --- /dev/null +++ b/.changeset/small-mice-fold.md @@ -0,0 +1,7 @@ +--- +'@tanstack/query-db-collection': patch +--- + +fix: default persisted query retention to gcTime when omitted + +When `persistedGcTime` is not provided, query collections now use the query's effective `gcTime` as the persisted retention TTL. This prevents unexpectedly early cleanup of persisted rows. diff --git a/packages/query-db-collection/src/query.ts b/packages/query-db-collection/src/query.ts index 45faca8f7..689d9ce9b 100644 --- a/packages/query-db-collection/src/query.ts +++ b/packages/query-db-collection/src/query.ts @@ -713,6 +713,7 @@ export function queryCollectionOptions( let syncStarted = false let startupRetentionSettled = false const retainedQueriesPendingRevalidation = new Set() + const effectivePersistedGcTimes = new Map() const persistedRetentionTimers = new Map< string, ReturnType @@ -1163,7 +1164,6 @@ export function queryCollectionOptions( ...(retryDelay !== undefined && { retryDelay }), ...(staleTime !== undefined && { staleTime }), } - const localObserver = new QueryObserver< Array, any, @@ -1171,9 +1171,19 @@ export function queryCollectionOptions( Array, any >(queryClient, observerOptions) + const resolvedQueryGcTime = queryClient.getQueryCache().find({ + queryKey: key, + exact: true, + })?.gcTime + const effectivePersistedGcTime = persistedGcTime ?? resolvedQueryGcTime hashToQueryKey.set(hashedQueryKey, key) state.observers.set(hashedQueryKey, localObserver) + if (effectivePersistedGcTime !== undefined) { + effectivePersistedGcTimes.set(hashedQueryKey, effectivePersistedGcTime) + } else { + effectivePersistedGcTimes.delete(hashedQueryKey) + } // Increment reference count for this query queryRefCounts.set( @@ -1526,6 +1536,7 @@ export function queryCollectionOptions( queryToRows.delete(hashedQueryKey) hashToQueryKey.delete(hashedQueryKey) queryRefCounts.delete(hashedQueryKey) + effectivePersistedGcTimes.delete(hashedQueryKey) } /** @@ -1535,6 +1546,8 @@ export function queryCollectionOptions( const cleanupQueryIfIdle = (hashedQueryKey: string) => { const refcount = queryRefCounts.get(hashedQueryKey) || 0 const observer = state.observers.get(hashedQueryKey) + const effectivePersistedGcTime = + effectivePersistedGcTimes.get(hashedQueryKey) if (refcount <= 0) { // Drop our subscription so hasListeners reflects only active consumers @@ -1561,7 +1574,7 @@ export function queryCollectionOptions( ) } - if (persistedGcTime !== undefined) { + if (effectivePersistedGcTime !== undefined) { if (metadata) { begin() metadata.collection.set( @@ -1569,20 +1582,20 @@ export function queryCollectionOptions( { queryHash: hashedQueryKey, mode: - persistedGcTime === Number.POSITIVE_INFINITY + effectivePersistedGcTime === Number.POSITIVE_INFINITY ? `until-revalidated` : `ttl`, - ...(persistedGcTime === Number.POSITIVE_INFINITY + ...(effectivePersistedGcTime === Number.POSITIVE_INFINITY ? {} - : { expiresAt: Date.now() + persistedGcTime }), + : { expiresAt: Date.now() + effectivePersistedGcTime }), }, ) commit() - if (persistedGcTime !== Number.POSITIVE_INFINITY) { + if (effectivePersistedGcTime !== Number.POSITIVE_INFINITY) { schedulePersistedRetentionExpiry({ queryHash: hashedQueryKey, mode: `ttl`, - expiresAt: Date.now() + persistedGcTime, + expiresAt: Date.now() + effectivePersistedGcTime, }) } } diff --git a/packages/query-db-collection/tests/query.test.ts b/packages/query-db-collection/tests/query.test.ts index 32c552c5d..95f825b98 100644 --- a/packages/query-db-collection/tests/query.test.ts +++ b/packages/query-db-collection/tests/query.test.ts @@ -4863,6 +4863,217 @@ describe(`QueryCollection`, () => { } }) + it(`should default persisted retention ttl to query gcTime when persistedGcTime is undefined`, async () => { + vi.useFakeTimers() + const gcTime = 120 + const gcTimeFallbackQueryClient = new QueryClient({ + defaultOptions: { + queries: { + gcTime, + staleTime: 0, + retry: false, + }, + }, + }) + + try { + const baseQueryKey = [`runtime-ttl-retention-default-gctime-test`] + const retainedQueryHash = hashKey(baseQueryKey) + const items: Array = [ + { id: `1`, name: `Retained`, category: `A` }, + ] + const queryFn = vi.fn().mockResolvedValue(items) + + const config: QueryCollectionConfig = { + id: `runtime-ttl-retention-default-gctime-test`, + queryClient: gcTimeFallbackQueryClient, + queryKey: () => baseQueryKey, + queryFn, + getKey: (item) => item.id, + syncMode: `on-demand`, + startSync: true, + } + + const baseOptions = queryCollectionOptions(config) + const originalSync = baseOptions.sync + const metadataHarness = createInMemorySyncMetadataApi< + string | number, + CategorisedItem + >({ + persistedRows: new Map(items.map((item) => [item.id, item])), + }) + + const collection = createCollection({ + ...baseOptions, + sync: { + sync: (params: Parameters[0]) => + originalSync.sync({ + ...params, + metadata: metadataHarness.api, + }), + }, + }) + + const liveQuery = createLiveQueryCollection({ + query: (q) => + q + .from({ item: collection }) + .where(({ item }) => eq(item.category, `A`)), + }) + + await liveQuery.preload() + await vi.waitFor(() => { + expect(collection.size).toBe(1) + }) + + await liveQuery.cleanup() + + const retentionEntry = metadataHarness.collectionMetadata.get( + `queryCollection:gc:${retainedQueryHash}`, + ) as + | { + queryHash: string + mode: `ttl` | `until-revalidated` + expiresAt?: number + } + | undefined + + expect(retentionEntry).toEqual({ + queryHash: retainedQueryHash, + mode: `ttl`, + expiresAt: expect.any(Number), + }) + expect(retentionEntry?.expiresAt).toBeGreaterThanOrEqual(Date.now()) + expect(retentionEntry?.expiresAt).toBeLessThanOrEqual( + Date.now() + gcTime, + ) + + await vi.advanceTimersByTimeAsync(gcTime + 25) + await vi.runOnlyPendingTimersAsync() + + expect( + metadataHarness.collectionMetadata.get( + `queryCollection:gc:${retainedQueryHash}`, + ), + ).toBeUndefined() + expect(collection.has(`1`)).toBe(false) + } finally { + gcTimeFallbackQueryClient.clear() + vi.useRealTimers() + } + }) + + it(`should default persisted retention to resolved query gcTime when queryClient gcTime is implicit`, async () => { + vi.useFakeTimers() + const implicitGcTimeQueryClient = new QueryClient({ + defaultOptions: { + queries: { + staleTime: 0, + retry: false, + }, + }, + }) + + try { + const baseQueryKey = [`runtime-ttl-retention-implicit-gctime-test`] + const retainedQueryHash = hashKey(baseQueryKey) + const items: Array = [ + { id: `1`, name: `Retained`, category: `A` }, + ] + const queryFn = vi.fn().mockResolvedValue(items) + + const config: QueryCollectionConfig = { + id: `runtime-ttl-retention-implicit-gctime-test`, + queryClient: implicitGcTimeQueryClient, + queryKey: () => baseQueryKey, + queryFn, + getKey: (item) => item.id, + syncMode: `on-demand`, + startSync: true, + } + + const baseOptions = queryCollectionOptions(config) + const originalSync = baseOptions.sync + const metadataHarness = createInMemorySyncMetadataApi< + string | number, + CategorisedItem + >({ + persistedRows: new Map(items.map((item) => [item.id, item])), + }) + + const collection = createCollection({ + ...baseOptions, + sync: { + sync: (params: Parameters[0]) => + originalSync.sync({ + ...params, + metadata: metadataHarness.api, + }), + }, + }) + + const liveQuery = createLiveQueryCollection({ + query: (q) => + q + .from({ item: collection }) + .where(({ item }) => eq(item.category, `A`)), + }) + + await liveQuery.preload() + await vi.waitFor(() => { + expect(collection.size).toBe(1) + }) + + const resolvedQueryGcTime = implicitGcTimeQueryClient + .getQueryCache() + .find({ queryKey: baseQueryKey, exact: true })?.gcTime + + expect(resolvedQueryGcTime).toBeDefined() + + await liveQuery.cleanup() + + const retentionEntry = metadataHarness.collectionMetadata.get( + `queryCollection:gc:${retainedQueryHash}`, + ) as + | { + queryHash: string + mode: `ttl` | `until-revalidated` + expiresAt?: number + } + | undefined + + if (resolvedQueryGcTime === Number.POSITIVE_INFINITY) { + expect(retentionEntry).toEqual({ + queryHash: retainedQueryHash, + mode: `until-revalidated`, + }) + } else { + expect(retentionEntry).toEqual({ + queryHash: retainedQueryHash, + mode: `ttl`, + expiresAt: expect.any(Number), + }) + expect(retentionEntry?.expiresAt).toBeGreaterThanOrEqual(Date.now()) + expect(retentionEntry?.expiresAt).toBeLessThanOrEqual( + Date.now() + resolvedQueryGcTime!, + ) + + await vi.advanceTimersByTimeAsync(resolvedQueryGcTime! + 25) + await vi.runOnlyPendingTimersAsync() + + expect( + metadataHarness.collectionMetadata.get( + `queryCollection:gc:${retainedQueryHash}`, + ), + ).toBeUndefined() + expect(collection.has(`1`)).toBe(false) + } + } finally { + implicitGcTimeQueryClient.clear() + vi.useRealTimers() + } + }) + it(`should reset refcount after query GC and reload (stale refcount bug)`, async () => { // This test catches Bug 2: stale refcounts after GC/remove // When TanStack Query GCs a query, the refcount should be cleaned up