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..b29aac873 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 @@ -1171,9 +1172,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 +1537,7 @@ export function queryCollectionOptions( queryToRows.delete(hashedQueryKey) hashToQueryKey.delete(hashedQueryKey) queryRefCounts.delete(hashedQueryKey) + effectivePersistedGcTimes.delete(hashedQueryKey) } /** @@ -1535,6 +1547,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,30 +1575,32 @@ export function queryCollectionOptions( ) } - if (persistedGcTime !== undefined) { - if (metadata) { - begin() - metadata.collection.set( - `${QUERY_COLLECTION_GC_PREFIX}${hashedQueryKey}`, - { - queryHash: hashedQueryKey, - mode: - persistedGcTime === Number.POSITIVE_INFINITY - ? `until-revalidated` - : `ttl`, - ...(persistedGcTime === Number.POSITIVE_INFINITY - ? {} - : { expiresAt: Date.now() + persistedGcTime }), - }, - ) - commit() - if (persistedGcTime !== Number.POSITIVE_INFINITY) { - schedulePersistedRetentionExpiry({ - queryHash: hashedQueryKey, - mode: `ttl`, - expiresAt: Date.now() + persistedGcTime, - }) - } + if ( + effectivePersistedGcTime !== undefined && + metadata && + persistedMetadata?.row.scanPersisted + ) { + begin() + metadata.collection.set( + `${QUERY_COLLECTION_GC_PREFIX}${hashedQueryKey}`, + { + queryHash: hashedQueryKey, + mode: + effectivePersistedGcTime === Number.POSITIVE_INFINITY + ? `until-revalidated` + : `ttl`, + ...(effectivePersistedGcTime === Number.POSITIVE_INFINITY + ? {} + : { expiresAt: Date.now() + effectivePersistedGcTime }), + }, + ) + commit() + if (effectivePersistedGcTime !== Number.POSITIVE_INFINITY) { + schedulePersistedRetentionExpiry({ + queryHash: hashedQueryKey, + mode: `ttl`, + expiresAt: Date.now() + effectivePersistedGcTime, + }) } unsubscribes.get(hashedQueryKey)?.() unsubscribes.delete(hashedQueryKey) 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