diff --git a/.changeset/clear-days-raise.md b/.changeset/clear-days-raise.md new file mode 100644 index 00000000..82bdceba --- /dev/null +++ b/.changeset/clear-days-raise.md @@ -0,0 +1,5 @@ +--- +"@tanstack/query-db-collection": patch +--- + +Add error tracking and retry methods to query collection utils. diff --git a/docs/guides/error-handling.md b/docs/guides/error-handling.md index 2ab15e13..7661c240 100644 --- a/docs/guides/error-handling.md +++ b/docs/guides/error-handling.md @@ -45,6 +45,52 @@ The error includes: - `issues`: Array of validation issues with messages and paths - `message`: A formatted error message listing all issues +## Query Collection Error Tracking + +Query collections provide enhanced error tracking utilities through the `utils` object. These methods expose error state information and provide recovery mechanisms for failed queries: + +```tsx +import { createCollection } from "@tanstack/db" +import { queryCollectionOptions } from "@tanstack/query-db-collection" +import { useLiveQuery } from "@tanstack/react-db" + +const syncedCollection = createCollection( + queryCollectionOptions({ + queryClient, + queryKey: ['synced-data'], + queryFn: fetchData, + getKey: (item) => item.id, + }) +) + +// Component can check error state +function DataList() { + const { data } = useLiveQuery((q) => q.from({ item: syncedCollection })) + const isError = syncedCollection.utils.isError() + const errorCount = syncedCollection.utils.errorCount() + + return ( + <> + {isError && errorCount > 3 && ( + + Unable to sync. Showing cached data. + + + )} + {/* Render data */} + + ) +} +``` + +Error tracking methods: +- **`lastError()`**: Returns the most recent error encountered by the query, or `undefined` if no errors have occurred: +- **`isError()`**: Returns a boolean indicating whether the collection is currently in an error state: +- **`errorCount()`**: Returns the number of consecutive sync failures. This counter is incremented only when queries fail completely (not per retry attempt) and is reset on successful queries: +- **`clearError()`**: Clears the error state and triggers a refetch of the query. This method resets both `lastError` and `errorCount`: + ## Collection Status and Error States Collections track their status and transition between states: @@ -281,6 +327,7 @@ When sync errors occur: - Error is logged to console: `[QueryCollection] Error observing query...` - Collection is marked as ready to prevent blocking the application - Cached data remains available +- Error tracking counters are updated (`lastError`, `errorCount`) ### Sync Write Errors diff --git a/packages/query-db-collection/src/query.ts b/packages/query-db-collection/src/query.ts index 23b58c98..73e71b4f 100644 --- a/packages/query-db-collection/src/query.ts +++ b/packages/query-db-collection/src/query.ts @@ -301,7 +301,7 @@ export interface QueryCollectionConfig< /** * Type for the refetch utility function */ -export type RefetchFn = () => Promise +export type RefetchFn = (opts?: { throwOnError?: boolean }) => Promise /** * Utility methods available on Query Collections for direct writes and manual operations. @@ -309,11 +309,13 @@ export type RefetchFn = () => Promise * @template TItem - The type of items stored in the collection * @template TKey - The type of the item keys * @template TInsertInput - The type accepted for insert operations + * @template TError - The type of errors that can occur during queries */ export interface QueryCollectionUtils< TItem extends object = Record, TKey extends string | number = string | number, TInsertInput extends object = TItem, + TError = unknown, > extends UtilsRecord { /** Manually trigger a refetch of the query */ refetch: RefetchFn @@ -327,6 +329,21 @@ export interface QueryCollectionUtils< writeUpsert: (data: Partial | Array>) => void /** Execute multiple write operations as a single atomic batch to the synced data store */ writeBatch: (callback: () => void) => void + /** Get the last error encountered by the query (if any); reset on success */ + lastError: () => TError | undefined + /** Check if the collection is in an error state */ + isError: () => boolean + /** + * Get the number of consecutive sync failures. + * Incremented only when query fails completely (not per retry attempt); reset on success. + */ + errorCount: () => number + /** + * Clear the error state and trigger a refetch of the query + * @returns Promise that resolves when the refetch completes successfully + * @throws Error if the refetch fails + */ + clearError: () => Promise } /** @@ -424,7 +441,8 @@ export function queryCollectionOptions< utils: QueryCollectionUtils< ResolveType, TKey, - TInsertInput + TInsertInput, + TError > } { type TItem = ResolveType @@ -467,6 +485,13 @@ export function queryCollectionOptions< throw new GetKeyRequiredError() } + /** The last error encountered by the query */ + let lastError: TError | undefined + /** The number of consecutive sync failures */ + let errorCount = 0 + /** The timestamp for when the query most recently returned the status as "error" */ + let lastErrorUpdatedAt = 0 + const internalSync: SyncConfig[`sync`] = (params) => { const { begin, write, commit, markReady, collection } = params @@ -500,6 +525,10 @@ export function queryCollectionOptions< type UpdateHandler = Parameters[0] const handleUpdate: UpdateHandler = (result) => { if (result.isSuccess) { + // Clear error state + lastError = undefined + errorCount = 0 + const newItemsArray = result.data if ( @@ -568,6 +597,12 @@ export function queryCollectionOptions< // Mark collection as ready after first successful query result markReady() } else if (result.isError) { + if (result.errorUpdatedAt !== lastErrorUpdatedAt) { + lastError = result.error + errorCount++ + lastErrorUpdatedAt = result.errorUpdatedAt + } + console.error( `[QueryCollection] Error observing query ${String(queryKey)}:`, result.error @@ -595,10 +630,15 @@ export function queryCollectionOptions< * Refetch the query data * @returns Promise that resolves when the refetch is complete */ - const refetch: RefetchFn = async (): Promise => { - return queryClient.refetchQueries({ - queryKey: queryKey, - }) + const refetch: RefetchFn = (opts) => { + return queryClient.refetchQueries( + { + queryKey: queryKey, + }, + { + throwOnError: opts?.throwOnError, + } + ) } // Create write context for manual write operations @@ -689,6 +729,15 @@ export function queryCollectionOptions< utils: { refetch, ...writeUtils, + lastError: () => lastError, + isError: () => !!lastError, + errorCount: () => errorCount, + clearError: () => { + lastError = undefined + errorCount = 0 + lastErrorUpdatedAt = 0 + return refetch({ throwOnError: true }) + }, }, } } diff --git a/packages/query-db-collection/tests/query.test.ts b/packages/query-db-collection/tests/query.test.ts index ecb82480..47a14a70 100644 --- a/packages/query-db-collection/tests/query.test.ts +++ b/packages/query-db-collection/tests/query.test.ts @@ -1632,4 +1632,318 @@ describe(`QueryCollection`, () => { expect.arrayContaining(initialItems) ) }) + + describe(`Error Handling`, () => { + // Helper to create test collection with common configuration + const createErrorHandlingTestCollection = ( + testId: string, + queryFn: ReturnType + ) => { + const config: QueryCollectionConfig = { + id: testId, + queryClient, + queryKey: [testId], + queryFn, + getKey, + startSync: true, + retry: false, + } + const options = queryCollectionOptions(config) + return createCollection(options) + } + + it(`should track error state, count, and support recovery`, async () => { + const initialData = [{ id: `1`, name: `Item 1` }] + const updatedData = [{ id: `1`, name: `Updated Item 1` }] + const errors = [new Error(`First error`), new Error(`Second error`)] + + const queryFn = vi + .fn() + .mockResolvedValueOnce(initialData) // Initial success + .mockRejectedValueOnce(errors[0]) // First error + .mockRejectedValueOnce(errors[1]) // Second error + .mockResolvedValueOnce(updatedData) // Recovery + + const collection = createErrorHandlingTestCollection( + `error-tracking-test`, + queryFn + ) + + // Wait for initial success - no errors + await vi.waitFor(() => { + expect(collection.status).toBe(`ready`) + expect(collection.utils.lastError()).toBeUndefined() + expect(collection.utils.isError()).toBe(false) + expect(collection.utils.errorCount()).toBe(0) + }) + + // First error - count increments + await collection.utils.refetch() + await vi.waitFor(() => { + expect(collection.utils.lastError()).toBe(errors[0]) + expect(collection.utils.errorCount()).toBe(1) + expect(collection.utils.isError()).toBe(true) + }) + + // Second error - count increments again + await collection.utils.refetch() + await vi.waitFor(() => { + expect(collection.utils.lastError()).toBe(errors[1]) + expect(collection.utils.errorCount()).toBe(2) + expect(collection.utils.isError()).toBe(true) + }) + + // Successful refetch resets error state + await collection.utils.refetch() + await vi.waitFor(() => { + expect(collection.utils.lastError()).toBeUndefined() + expect(collection.utils.isError()).toBe(false) + expect(collection.utils.errorCount()).toBe(0) + expect(collection.get(`1`)).toEqual(updatedData[0]) + }) + }) + + it(`should support manual error recovery with clearError`, async () => { + const recoveryData = [{ id: `1`, name: `Item 1` }] + const testError = new Error(`Test error`) + + const queryFn = vi + .fn() + .mockRejectedValueOnce(testError) + .mockResolvedValueOnce(recoveryData) + .mockRejectedValueOnce(testError) + + const collection = createErrorHandlingTestCollection( + `clear-error-test`, + queryFn + ) + + // Wait for initial error + await vi.waitFor(() => { + expect(collection.utils.isError()).toBe(true) + expect(collection.utils.errorCount()).toBe(1) + }) + + // Manual error clearing triggers refetch + await collection.utils.clearError() + + expect(collection.utils.lastError()).toBeUndefined() + expect(collection.utils.isError()).toBe(false) + expect(collection.utils.errorCount()).toBe(0) + + await vi.waitFor(() => { + expect(collection.get(`1`)).toEqual(recoveryData[0]) + }) + + // Refetch on rejection should throw an error + await expect(collection.utils.clearError()).rejects.toThrow(testError) + expect(collection.utils.lastError()).toBe(testError) + expect(collection.utils.isError()).toBe(true) + expect(collection.utils.errorCount()).toBe(1) + }) + + it(`should maintain collection functionality despite errors and persist error state`, async () => { + const initialData = [ + { id: `1`, name: `Item 1` }, + { id: `2`, name: `Item 2` }, + ] + const testError = new Error(`Query error`) + + const queryFn = vi + .fn() + .mockResolvedValueOnce(initialData) + .mockRejectedValue(testError) + + const collection = createErrorHandlingTestCollection( + `functionality-with-errors-test`, + queryFn + ) + + await vi.waitFor(() => { + expect(collection.status).toBe(`ready`) + expect(collection.size).toBe(2) + }) + + // Cause error + await collection.utils.refetch() + await vi.waitFor(() => { + expect(collection.utils.errorCount()).toBe(1) + expect(collection.utils.isError()).toBe(true) + }) + + // Collection operations still work with cached data + expect(collection.size).toBe(2) + expect(collection.get(`1`)).toEqual(initialData[0]) + expect(collection.get(`2`)).toEqual(initialData[1]) + + // Manual write operations work and clear error state + const newItem = { id: `3`, name: `Manual Item` } + collection.utils.writeInsert(newItem) + expect(collection.size).toBe(3) + expect(collection.get(`3`)).toEqual(newItem) + + await flushPromises() + + // Manual writes clear error state + expect(collection.utils.lastError()).toBeUndefined() + expect(collection.utils.isError()).toBe(false) + expect(collection.utils.errorCount()).toBe(0) + + // Create error state again for persistence test + await collection.utils.refetch() + await vi.waitFor(() => expect(collection.utils.isError()).toBe(true)) + + const originalError = collection.utils.lastError() + const originalErrorCount = collection.utils.errorCount() + + // Read-only operations don't affect error state + expect(collection.has(`1`)).toBe(true) + const changeHandler = vi.fn() + const unsubscribe = collection.subscribeChanges(changeHandler) + + expect(collection.utils.lastError()).toBe(originalError) + expect(collection.utils.isError()).toBe(true) + expect(collection.utils.errorCount()).toBe(originalErrorCount) + + unsubscribe() + }) + + it(`should handle custom error objects correctly`, async () => { + interface CustomError { + code: string + message: string + details?: Record + } + const customError: CustomError = { + code: `NETWORK_ERROR`, + message: `Failed to fetch data`, + details: { retryAfter: 5000 }, + } + + // Start with error immediately - no initial success needed + const queryFn = vi.fn().mockRejectedValue(customError) + + const config: QueryCollectionConfig< + TestItem, + never, + typeof queryFn, + CustomError + > = { + id: `custom-error-test`, + queryClient, + queryKey: [`custom-error-test`], + queryFn, + getKey, + startSync: true, + retry: false, + } + + const options = queryCollectionOptions(config) + const collection = createCollection(options) + + // Wait for collection to be ready (even with error) + await vi.waitFor(() => { + expect(collection.status).toBe(`ready`) + expect(collection.utils.isError()).toBe(true) + }) + + // Verify custom error is accessible with all its properties + const lastError = collection.utils.lastError() + expect(lastError).toBe(customError) + expect(lastError?.code).toBe(`NETWORK_ERROR`) + expect(lastError?.message).toBe(`Failed to fetch data`) + expect(lastError?.details?.retryAfter).toBe(5000) + expect(collection.utils.errorCount()).toBe(1) + }) + + it(`should persist error state after collection cleanup`, async () => { + const testError = new Error(`Persistent error`) + + // Start with error immediately + const queryFn = vi.fn().mockRejectedValue(testError) + + const collection = createErrorHandlingTestCollection( + `error-persistence-cleanup-test`, + queryFn + ) + + // Wait for collection to be ready (even with error) + await vi.waitFor(() => { + expect(collection.status).toBe(`ready`) + expect(collection.utils.isError()).toBe(true) + }) + + // Verify error state before cleanup + expect(collection.utils.lastError()).toBe(testError) + expect(collection.utils.errorCount()).toBe(1) + + // Cleanup collection + await collection.cleanup() + expect(collection.status).toBe(`cleaned-up`) + + // Error state should persist after cleanup + expect(collection.utils.isError()).toBe(true) + expect(collection.utils.lastError()).toBe(testError) + expect(collection.utils.errorCount()).toBe(1) + }) + + it(`should increment errorCount only after final failure when using Query retries`, async () => { + const testError = new Error(`Retry test error`) + const retryCount = 2 + const totalAttempts = retryCount + 1 + + // Create a queryFn that fails consistently + const queryFn = vi.fn().mockRejectedValue(testError) + + // Create collection with retry enabled (2 retries = 3 total attempts) + const config: QueryCollectionConfig = { + id: `retry-semantics-test`, + queryClient, + queryKey: [`retry-semantics-test`], + queryFn, + getKey, + startSync: true, + retry: retryCount, // This will result in 3 total attempts (initial + 2 retries) + retryDelay: 5, // Short delay for faster tests + } + + const options = queryCollectionOptions(config) + const collection = createCollection(options) + + // Wait for all retry attempts to complete and final failure + await vi.waitFor( + () => { + expect(collection.status).toBe(`ready`) // Should be ready even with error + expect(queryFn).toHaveBeenCalledTimes(totalAttempts) + expect(collection.utils.isError()).toBe(true) + }, + { timeout: 2000 } + ) + + // Error count should only increment once after all retries are exhausted + // This ensures we track "consecutive post-retry failures," not per-attempt failures + expect(collection.utils.errorCount()).toBe(1) + expect(collection.utils.lastError()).toBe(testError) + expect(collection.utils.isError()).toBe(true) + + // Reset attempt counter for second test + queryFn.mockClear() + + // Trigger another refetch which should also retry and fail + await collection.utils.refetch() + + // Wait for the second set of retries to complete + await vi.waitFor( + () => { + expect(queryFn).toHaveBeenCalledTimes(totalAttempts) + }, + { timeout: 2000 } + ) + + // Error count should now be 2 (two post-retry failures) + expect(collection.utils.errorCount()).toBe(2) + expect(collection.utils.lastError()).toBe(testError) + expect(collection.utils.isError()).toBe(true) + }) + }) })